Greasy Fork

Greasy Fork is available in English.

少数派 AI 助手

为少数派文章添加 AI 辅助功能,支持一键总结文章内容和评论

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         少数派 AI 助手
// @name:en      SSPAI AI Assistant
// @namespace    https://github.com/Jandaes/sspai-ai-assistant
// @version      0.1.1
// @description  为少数派文章添加 AI 辅助功能,支持一键总结文章内容和评论
// @description:en  Add AI assistant features to SSPAI articles, support one-click summary of article content and comments
// @author       janda
// @homepage     https://github.com/Jandaes/sspai-ai-assistant
// @supportURL   https://github.com/Jandaes/sspai-ai-assistant/issues
// @match        https://sspai.com/post/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      *
// @icon         https://cdn-static.sspai.com/favicon/sspai.ico
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // 样式注入
    const styles = `
        .article-summary-panel {
            display: none;
            position: fixed;
            left: 20px;
            top: 50%;
            transform: translateY(-50%);
            width: 500px;
            max-height: 80vh;
            border: 1px solid #eee;
            border-radius: 8px;
            z-index: 9999;
            padding: 15px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow-y: auto;
        }

        .settings-modal {
            display: none;
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 500px;
            background: #1a1a1a;
            color: #fff;
            border-radius: 8px;
            padding: 20px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
            z-index: 10000;
        }

        .modal-backdrop {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.5);
            z-index: 9999;
        }

        .settings-group {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 15px;
            padding: 0 20px;
        }

        .settings-group label {
            flex: 0 0 100px;
            margin-bottom: 0;
            font-weight: bold;
            text-align: right;
            padding-right: 15px;
            color: #fff;
        }

        .settings-group .input-wrapper {
            flex: 1;
        }

        .settings-group input, 
        .settings-group textarea {
            width: 100%;
            padding: 8px;
            border: 1px solid #333;
            border-radius: 4px;
            background: #2d2d2d;
            color: #fff;
        }

        .settings-group textarea {
            height: 100px;
            resize: vertical;
        }

        .modal-title {
            text-align: center;
            margin-bottom: 20px;
            padding-bottom: 10px;
            border-bottom: 1px solid #333;
            color: #fff;
        }

        .modal-footer {
            margin-top: 20px;
            text-align: right;
            padding: 0 20px;
        }

        .modal-footer button {
            padding: 8px 16px;
            margin-left: 10px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }

        .save-btn {
            background: #D7000F;
            color: white;
        }

        .cancel-btn {
            background: #333;
            color: #fff;
        }
    `;

    GM_addStyle(styles);

    // 初始化函数
    function init() {
        createSideButtons();
        createSummaryPanel();
    }

    // 创建侧边栏按钮
    function createSideButtons() {
        const waitForSideBar = setInterval(() => {
            const sideBar = document.querySelector('div.article-body > div.article-side.sideTop');
            if (!sideBar) return;

            clearInterval(waitForSideBar);

            // 创建总结按钮
            const summaryWrapper = document.createElement('div');
            summaryWrapper.setAttribute('data-v-30f3dc0c', '');
            summaryWrapper.className = 'item-wrapper';
            summaryWrapper.title = '总结';
            summaryWrapper.style.cursor = 'pointer';
            summaryWrapper.innerHTML = `✨`;
            summaryWrapper.addEventListener('click', handleSummary);

            // 创建设置按钮
            const settingsWrapper = document.createElement('div');
            settingsWrapper.setAttribute('data-v-30f3dc0c', '');
            settingsWrapper.className = 'item-wrapper';                
            settingsWrapper.title = '设置';
            settingsWrapper.style.cursor = 'pointer';
            settingsWrapper.innerHTML = `⚙️`;
            settingsWrapper.addEventListener('click', handleSettings);

            sideBar.appendChild(summaryWrapper);
            sideBar.appendChild(settingsWrapper);
        }, 500);

        setTimeout(() => clearInterval(waitForSideBar), 10000);
    }

    // 创建总结面板
    function createSummaryPanel() {
        const panel = document.createElement('div');
        panel.className = 'article-summary-panel';
        panel.innerHTML = `
            <h3>AI 总结</h3>
            <div class="summary-content" style="white-space: pre-wrap;"></div>
        `;
        document.body.appendChild(panel);
    }

    // 获取文章内容
    function getArticleContent() {        
        const articleBody = document.querySelector('div.content.wangEditor-txt.minHeight');
        if (!articleBody) return '';
        
        // 获取纯文本并清理空格和换行
        return Array.from(articleBody.childNodes)
            .filter(node => !['pre', 'code', 'script', 'style'].includes(node.nodeName.toLowerCase()))
            .map(node => node.textContent)
            .join('')  // 不添加换行符
            .replace(/\s+/g, '')  // 移除所有空白字符
            .trim();
    }

    // 处理总结按钮点击
    async function handleSummary() {
        const panel = document.querySelector('.article-summary-panel');
        const content = panel?.querySelector('.summary-content');
        if (!panel || !content) return;

        panel.style.display = 'block';
        content.textContent = '正在获取内容并生成总结...';

        try {
            const articleContent = getArticleContent();
            if (!articleContent) throw new Error('无法获取文章内容');

            const comments = await fetchComments();
            const summary = await callLLMApi(
                `文章内容:${articleContent}\n\n评论内容:${comments.join('\n')}`
            );
            
            // 原样输出 AI 返回的内容,不做任何处理
            content.textContent = summary;
        } catch (error) {
            content.textContent = `生成总结时出错: ${error.message}`;
        }
    }

    // 获取评论数据
    async function fetchComments() {
        const articleId = window.location.pathname.match(/^\/post\/(\d+)$/)?.[1];
        if (!articleId) return [];

        let allComments = [];
        let offset = 0;
        const limit = 20;

        try {
            while (true) {
                const response = await fetch(
                    `https://sspai.com/api/v1/comment/user/article/hot/page/get?limit=${limit}&offset=${offset}&article_id=${articleId}`
                );
                const data = await response.json();
                
                if (data.error !== 0 || !data.data?.length) break;
                
                allComments = allComments.concat(data.data);
                if (offset + limit >= (data.total || 0)) break;
                offset += limit;
            }

            // 清理评论内容
            return allComments.reduce((acc, item) => {
                // 添加主评论
                acc.push(item.comment.replace(/\s+/g, '').trim());
                // 添加回复评论
                item.reply?.forEach(reply => {
                    acc.push(reply.comment.replace(/\s+/g, '').trim());
                });
                return acc;
            }, []);
        } catch (error) {
            console.error('获取评论失败:', error);
            return [];
        }
    }

    // 调用 AI API
    async function callLLMApi(content) {
        const settings = {
            apiUrl: GM_getValue('apiUrl', ''),
            apiKey: GM_getValue('apiKey', ''),
            modelName: GM_getValue('modelName', 'gpt-3.5-turbo'),
            systemPrompt: GM_getValue('systemPrompt', '你只需要精炼总结文章内容和评论、不需要加入你的任何观点。分别输出文章内容和用户评论')
        };

        if (!settings.apiUrl || !settings.apiKey) {
            return '请先在设置中配置 API 地址和 API Key';
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: settings.apiUrl,
                headers: {
                    'Authorization': `Bearer ${settings.apiKey}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({
                    messages: [
                        { role: 'system', content: settings.systemPrompt },
                        { role: 'user', content: content }
                    ],
                    model: settings.modelName,
                    stream: false
                }),
                onload: response => {
                    try {
                        if (response.status === 200) {
                            const data = JSON.parse(response.responseText);
                            if (data.choices?.[0]?.message?.content) {
                                resolve(data.choices[0].message.content);
                            } else {
                                reject(new Error('API 响应格式不正确'));
                            }
                        } else {
                            reject(new Error(`API 请求失败: ${response.status}`));
                        }
                    } catch (error) {
                        reject(new Error(`解析响应失败: ${error.message}`));
                    }
                },
                onerror: error => reject(new Error(error.error || '网络请求失败')),
                ontimeout: () => reject(new Error('请求超时')),
                timeout: 30000
            });
        });
    }

    // 处理设置
    function handleSettings() {
        const modal = document.createElement('div');
        modal.className = 'settings-modal';
        modal.innerHTML = `
            <h3 class="modal-title">AI 助手设置</h3>
            <div class="settings-group">
                <label>API 地址</label>
                <div class="input-wrapper">
                    <input type="text" id="api-url" placeholder="请输入 API 地址">
                </div>
            </div>
            <div class="settings-group">
                <label>API Key</label>
                <div class="input-wrapper">
                    <input type="password" id="api-key" placeholder="请输入 API Key">
                </div>
            </div>
            <div class="settings-group">
                <label>模型名称</label>
                <div class="input-wrapper">
                    <input type="text" id="model-name" placeholder="例如:gpt-3.5-turbo">
                </div>
            </div>
            <div class="settings-group">
                <label>系统提示词</label>
                <div class="input-wrapper">
                    <textarea id="system-prompt" placeholder="请输入系统提示词"></textarea>
                </div>
            </div>
            <div class="modal-footer">
                <button class="cancel-btn">取消</button>
                <button class="save-btn">保存</button>
            </div>
        `;
        
        const backdrop = document.createElement('div');
        backdrop.className = 'modal-backdrop';
        
        document.body.appendChild(modal);
        document.body.appendChild(backdrop);

        // 填充已保存的设置
        document.getElementById('api-url').value = GM_getValue('apiUrl', '');
        document.getElementById('api-key').value = GM_getValue('apiKey', '');
        document.getElementById('model-name').value = GM_getValue('modelName', 'gpt-3.5-turbo');
        document.getElementById('system-prompt').value = GM_getValue('systemPrompt', '你只需要精炼总结文章内容和评论、不需要加入你的任何观点。分别输出文章内容和用户评论');

        // 显示模态框
        modal.style.display = backdrop.style.display = 'block';

        // 保存设置
        modal.querySelector('.save-btn').addEventListener('click', () => {
            const newSettings = {
                apiUrl: document.getElementById('api-url').value.trim(),
                apiKey: document.getElementById('api-key').value.trim(),
                modelName: document.getElementById('model-name').value.trim(),
                systemPrompt: document.getElementById('system-prompt').value.trim()
            };

            Object.entries(newSettings).forEach(([key, value]) => {
                if (value) GM_setValue(key, value);
            });

            modal.remove();
            backdrop.remove();
        });

        // 取消按钮
        modal.querySelector('.cancel-btn').addEventListener('click', () => {
            modal.remove();
            backdrop.remove();
        });

        // 点击背景关闭
        backdrop.addEventListener('click', () => {
            modal.remove();
            backdrop.remove();
        });
    }

    // 页面加载完成后初始化
    window.addEventListener('load', init);
})();