Greasy Fork

Greasy Fork is available in English.

V2EX 文章总结助手

为 V2EX 帖子生成总结

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         V2EX 文章总结助手
// @name:zh-CN   V2EX 文章总结助手
// @namespace    https://github.com/jandaes/v2ex_ai
// @version      2.0.1
// @description  为 V2EX 帖子生成总结
// @description:zh-CN  为 V2EX 帖子生成总结
// @author       Jandaes
// @homepage     http://greasyfork.icu/zh-CN/scripts/521732-v2ex-%E6%96%87%E7%AB%A0%E6%80%BB%E7%BB%93%E5%8A%A9%E6%89%8B
// @supportURL   https://github.com/Jandaes/v2ex_ai
// @match        *.v2ex.com/*
// @connect      *
// @grant        GM_xmlhttpRequest
// @icon         https://www.v2ex.com/favicon.ico
// @license      MIT
// @copyright    2024, Jandaes (https://github.com/Jandaes)
// ==/UserScript==

(function(){
    'use strict';
    const d=document,ls=localStorage,w=window;
    const $=(s,p=d)=>p.querySelector(s);
    const t={dark:{bg:'#2d2d2d',t:'#e0e0e0',i:'#3d3d3d',b:'#4d4d4d'},light:{bg:'#fff',t:'#333',i:'#f5f5f5',b:'#ddd'}};
    const STORAGE_KEY = 'v2ex_summary_settings';
    const DEFAULT_SETTINGS = {
        apiUrl: '',
        apiKey: '',
        modelName: '',
        prompt: '只精简总结文章内容和评论的核心要点、不需要加入你的任何观点。分别输出文章内容和用户评论',
        theme: 'system'  // 默认跟随系统
    };
    const store = {
        get: () => {
            try {
                return { ...DEFAULT_SETTINGS, ...JSON.parse(ls.getItem(STORAGE_KEY) || '{}') };
            } catch (e) {
                return { ...DEFAULT_SETTINGS };
            }
        },
        set: (settings) => {
            ls.setItem(STORAGE_KEY, JSON.stringify({ ...store.get(), ...settings }));
        }
    };
    
    function modal(){
        // 获取当前主题
        const settings = store.get();
        const isDark = settings.theme === 'dark' || 
                       (settings.theme === 'system' && w.matchMedia('(prefers-color-scheme:dark)').matches);
        const th = t[isDark ? 'dark' : 'light'];

        const m = createElement('div', {
            style: `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);display:flex;justify-content:center;align-items:center;z-index:1000`
        });

        const c = createElement('div', {
            style: `
                position:relative;
                background:${th.bg};
                padding:25px;
                border-radius:12px;
                width:450px;
                max-width:90%;
                color:${th.t};
                padding-bottom:20px;
                border:1px solid ${th.b}
            `
        });

        m.appendChild(c);

        c.innerHTML = `
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;border-bottom:1px solid ${th.b};padding-bottom:10px">
                <h3 style="margin:0;font-size:18px;color:${th.t}">V2EX 文章总结助手设置</h3>
                <div style="display:flex;align-items:center;gap:8px">
                    <span style="font-size:14px">主题</span>
                    <select id="theme" style="padding:4px 8px;background:${th.i};color:${th.t};border:1px solid ${th.b};border-radius:4px">
                        <option value="system">跟随系统</option>
                        <option value="light">浅色</option>
                        <option value="dark">深色</option>
                    </select>
                </div>
            </div>
            <div class="form">
                <div class="group"><label>API URL:</label><input id="url" placeholder="输入API地址"></div>
                <div class="group"><label>API Key:</label><div class="pwd"><input type="password" id="key" placeholder="输入API Key"><span class="eye">🔒</span></div></div>
                <div class="group"><label>模型名称:</label><input id="model" placeholder="输入模型名称"></div>
                <div class="group"><label>系统提示词:</label><textarea id="prompt" placeholder="请输入"></textarea></div>
            </div>
            <div style="display:flex;justify-content:space-between;align-items:center;margin-top:25px">
                <a href="https://github.com/Jandaes/v2ex_ai" target="_blank" class="github">
                    <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
                    GitHub
                </a>
                <div style="display:flex;gap:10px">
                    <button id="cancel">取消</button>
                    <button id="save" class="primary">保存</button>
                </div>
            </div>
        `;

        addStyle(c, `
            .form{display:flex;flex-direction:column;gap:15px}
            .group{display:flex;align-items:center}
            .group label{width:85px;text-align:right;margin-right:15px;color:${th.t}}
            .group input,.group textarea{
                flex:1;
                padding:8px 12px;
                border:1px solid ${th.b};
                border-radius:6px;
                background:${th.i};
                color:${th.t}
            }
            .group textarea{height:100px;resize:vertical}
            .pwd{position:relative;flex:1;display:flex}
            .eye{position:absolute;right:12px;top:50%;transform:translateY(-50%);cursor:pointer;user-select:none;opacity:.7}
            button{
                padding:8px 16px;
                border:none;
                border-radius:6px;
                background:${th.i};
                color:${th.t};
                cursor:pointer
            }
            .primary{background:#0066cc;color:#fff}
            .github{color:${th.t};text-decoration:none;opacity:.8;display:flex;align-items:center;gap:6px;font-size:14px}
        `);

        // 加载设置
        $('#url', c).value = settings.apiUrl;
        $('#key', c).value = settings.apiKey;
        $('#model', c).value = settings.modelName;
        $('#prompt', c).value = settings.prompt;
        $('#theme', c).value = settings.theme;

        // 添加主题切换事件
        $('#theme', c).onchange = function() {
            const newTheme = this.value;
            const isDark = newTheme === 'dark' || 
                          (newTheme === 'system' && w.matchMedia('(prefers-color-scheme:dark)').matches);
            const th = t[isDark ? 'dark' : 'light'];

            // 更新所有颜色
            c.style.background = th.bg;
            c.style.color = th.t;
            c.style.borderColor = th.b;

            // 更新所有输入框和按钮
            c.querySelectorAll('input, textarea, select').forEach(el => {
                el.style.background = th.i;
                el.style.color = th.t;
                el.style.borderColor = th.b;
            });

            // 更新标签颜色
            c.querySelectorAll('label, h3, .github').forEach(el => {
                el.style.color = th.t;
            });

            // 更新普通按钮
            c.querySelectorAll('button:not(.primary)').forEach(el => {
                el.style.background = th.i;
                el.style.color = th.t;
            });
        };

        // 将 modal 添加到 body
        d.body.appendChild(m);

        // 绑定事件
        $('.eye', c).onclick = e => {
            const i = $('#key', c);
            i.type = i.type === 'password' ? 'text' : 'password';
            e.target.textContent = i.type === 'password' ? '🔒' : '🔓';
        };

        $('#save', c).onclick = () => {
            store.set({
                apiUrl: $('#url', c).value,
                apiKey: $('#key', c).value,
                modelName: $('#model', c).value,
                prompt: $('#prompt', c).value
            });
            m.remove();
        };

        $('#cancel', c).onclick = () => m.remove();
        m.onclick = e => { if(e.target === m) m.remove(); };
    }

    function summary(){
        // 检查是否是文章页面(URL 包含 /t/数字)
        if (!w.location.pathname.match(/^\/t\/\d+/)) return;
        
        // 获取 gray 元素
        const gray = $('#Main .box .header .gray');
        if (!gray) {
            // 如果没找到元素,等待后重试
            setTimeout(summary, 500);  // 增加延迟时间
            return;
        }
        
        // 避免重复添加
        if (gray.querySelector('.summary-tools')) return;
        
        // 创建一个容器来包裹总结和设置按钮
        const toolsContainer = createElement('span', {
            className: 'summary-tools',
            style: 'display: inline-block; margin-left: 5px'  // 修改样式确保显示
        });
        
        // 创建总结按钮
        const sum = createElement('a', {
            href: 'javascript:void(0)',
            className: 'tb summary-button',
            innerHTML: '总结 <span style="font-size:14px">✨</span>',
            style: 'margin-left: 5px'  // 添加间距
        });
        
        // 创建设置按钮
        const set = createElement('a', {
            href: 'javascript:void(0)',
            className: 'tb settings-button',
            innerHTML: '设置 <span style="font-size:14px">⚙️</span>',
            style: 'margin-left: 5px'  // 添加间距
        });
        
        // 绑定点击事件
        sum.onclick = async () => {
            // 获取文章内容,如果没有内容则使用空字符串
            const content = getContent() || '';
            
            const container = getContainer();
            if(!container) return;
            
            const cont = $('.summary-content',container);
            
            // 如果已经有内容且不是错误消息,直接显示
            if(container.style.display==='none' && 
               cont.innerHTML && 
               !cont.innerHTML.includes('失败')) {
                container.style.display='block';
                return;
            }
            
            // 显示加载状态
            cont.textContent='正在获取评论...';
            container.style.display='block';
            
            // 获取所有评论
            const comments = await getAllComments();            
            // 组合文章内容和评论
            const fullContent = `
文章内容:
${content}

评论内容:
${comments.map(c => c.trim()).join(' ')}`;
            
            // 更新状态
            cont.textContent='正在生成总结...';
            
            // 发送到 LLM
            const sum = await request(fullContent);
            if(sum){
                cont.innerHTML = sum;
            }else{
                cont.textContent='生成总结失败,请检查设置和网络连接';
            }
        };
        set.onclick = modal;
        
        // 将按钮添加到容器中
        toolsContainer.appendChild(document.createTextNode(' • '));
        toolsContainer.appendChild(sum);
        toolsContainer.appendChild(document.createTextNode(' • '));
        toolsContainer.appendChild(set);
        
        // 将容器添加到 gray 元素中
        gray.appendChild(toolsContainer);
    }

    async function getAllComments() {
        let allComments = [];
        
        // 获取分页信息
        const pagination = $('.cell.ps_container');
        let pageInfo = {
            currentPage: 1,
            totalPages: 1
        };
        
        if(pagination) {
            const current = pagination.querySelector('div.page_current');
            if(current) {
                pageInfo.currentPage = parseInt(current.textContent);
            }
            
            const pages = [...pagination.querySelectorAll('a.page_normal')];
            if(pages.length > 0) {
                const lastPage = parseInt(pages[pages.length - 1].textContent);
                pageInfo.totalPages = Math.max(lastPage, pageInfo.currentPage);
            }
        }        
        // 获取所有页面的评论
        const topicId = w.location.pathname.match(/\/t\/(\d+)/)?.[1];
        if(topicId) {
            for(let page = 1; page <= pageInfo.totalPages; page++) {
                try {
                    if(page === pageInfo.currentPage) {
                        // 如果是当前页,直接获取DOM中的评论
                        allComments = allComments.concat(getPageComments(d));
                    } else {
                        // 获取其他页面的评论
                        const response = await fetch(`https://www.v2ex.com/t/${topicId}?p=${page}`);
                        const text = await response.text();
                        const parser = new DOMParser();
                        const doc = parser.parseFromString(text, 'text/html');
                        
                        const pageComments = getPageComments(doc);
                        allComments = allComments.concat(pageComments);
                    }
                    
                    if(page < pageInfo.totalPages) {
                        await new Promise(resolve => setTimeout(resolve, 500));
                    }                    
                } catch(e) {
                    console.error(`获取第 ${page} 页评论失败:`, e);
                }
            }
        }
        
        return allComments;
    }

    function getPageComments(doc) {
        return [...doc.querySelectorAll('div[id^="r_"].cell')]
            .map(comment => comment.querySelector('.reply_content')?.textContent
                .replace(/\s+/g, ' ')  // 将多个空白字符替换为单个空格
                .trim())
            .filter(Boolean);  // 过滤掉空评论
    }

    function getContainer(){
        // 检查是否存在容器
        const existingContainer = $('.summary-container');
        if (existingContainer) return existingContainer;
        
        // 取 #Main .box 元素
        const mainBox = $('#Main .box');
        if (!mainBox) return null;
        
        // 创建总结容器,添加圆角边框样式
        const c = createElement('div', {
            className: 'summary-container cell',
            style: `padding:15px;font-size:14px;line-height:1.6;display:none;border-radius:6px;border:1px solid var(--box-border-color,#eee)`
        });
        
        const tb = createElement('div', {
            style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--box-border-color,#eee)'
        });
        
        const tl = createElement('div', {style: 'display:flex;align-items:center;gap:10px'});
        const title = createElement('div', {innerHTML: '📝 文章总结', style: 'font-weight:500'});
        const regen = createElement('a', {
            href: 'javascript:void(0)',
            className: 'tb',
            innerHTML: '🔄 重新生成',
            style: 'font-size:12px'
        });
        
        regen.onclick = async () => {
            const content = getContent();
            if (!content) return;
            
            const cont = $('.summary-content');
            cont.textContent = '正在重新生成总结...';
            
            const sum = await request(content);
            if (sum) {
                cont.innerHTML = sum;
            } else {
                cont.textContent = '生成总结失败,请检查设置和网络连接';
            }
        };
        
        tl.appendChild(title);
        tl.appendChild(regen);
        
        const close = createElement('span', {
            innerHTML: '✕',
            style: 'cursor:pointer;opacity:.6;font-size:16px;padding:4px 8px'
        });
        close.onclick = () => c.style.display = 'none';
        
        tb.appendChild(tl);
        tb.appendChild(close);
        c.appendChild(tb);
        
        const cont = createElement('div', {
            className: 'summary-content',
            style: 'white-space:pre-wrap;word-break:break-word;text-align:left;padding:10px 0;line-height:1.8'
        });
        c.appendChild(cont);
        
        // 将容器插入到第一个 cell 之前
        const firstCell = mainBox.querySelector('.cell');
        if (firstCell) {
            mainBox.insertBefore(c, firstCell);
        } else {
            mainBox.appendChild(c);
        }
        
        return c;
    }

    async function request(content, retries = 3, timeout = 10000) {
        const settings = store.get();
        if (!settings.apiUrl || !settings.apiKey || !settings.modelName) {
            alert('请先完成设置(API URL、API Key 和模型名称为必填项)');
            return null;
        }

        const fetchWithTimeout = (url, options, timeout) => {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: options.method,
                    url: url,
                    headers: options.headers,
                    data: options.body,
                    timeout: timeout,
                    onload: function(response) {
                        resolve({
                            ok: response.status >= 200 && response.status < 300,
                            status: response.status,
                            json: () => JSON.parse(response.responseText)
                        });
                    },
                    onerror: function(error) {
                        reject(new Error('Network error'));
                    },
                    ontimeout: function() {
                        reject(new Error('Request timeout'));
                    }
                });
            });
        };

        for (let i = 0; i < retries; i++) {
            try {
                const r = await fetchWithTimeout(settings.apiUrl, {
                    method: 'POST',
                    headers: {
                        'Authorization': `Bearer ${settings.apiKey}`,
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        messages: [
                            {role: "system", content: settings.prompt},
                            {role: "user", content}
                        ],
                        model: settings.modelName,
                        stream: false
                    })
                }, timeout);

                if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
                const d = await r.json();
                return d.choices?.[0]?.message?.content || '总结生成失败,请检查API返回格式';

            } catch (e) {
                if (i === retries - 1) {
                    alert(`请求失败: ${e.message}`);
                    return null;
                }
                await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
                console.log(`第 ${i + 1} 次重试失败,准备重试...`);
            }
        }
    }

    function createElement(tag,props={}){
        const el=d.createElement(tag);
        Object.assign(el,props);
        return el;
    }

    function addStyle(el,css){
        const s=createElement('style');
        s.textContent=css;
        el.appendChild(s);
    }

    function getContent() {
        const contentElement = document.querySelector('#Main .topic_content');
        return contentElement ? contentElement.innerText : '';
    }

    function summarizeContent() {
        const content = getContent();
        // 如果内容为空,可以提前返回或显示提示
        if (!content) {
            alert('未找到文章内容');
            return;
        }
        // 其余代码...
    }

    function addButton() {
        const mainElement = document.querySelector('#Main');
        if (!mainElement) return;

        const button = document.createElement('button');
        button.textContent = '总结内容';
        button.style.marginBottom = '10px';
        button.onclick = summarizeContent; // 直接使用函数引用

        const resummaryButton = document.createElement('button');
        resummaryButton.textContent = '重新总结';
        resummaryButton.style.marginLeft = '10px';
        resummaryButton.style.marginBottom = '10px';
        resummaryButton.onclick = summarizeContent; // 同样直接使用函数引用

        mainElement.insertBefore(button, mainElement.firstChild);
        mainElement.insertBefore(resummaryButton, mainElement.firstChild.nextSibling);
    }

    // 为了处理可能的动态加载情况,添加 MutationObserver
    const observer = new MutationObserver((mutations, obs) => {
        if (!w.location.pathname.match(/^\/t\/\d+/)) return;
        
        const gray = $('#Main .box .header .gray');
        if (gray && !gray.querySelector('.summary-tools')) {
            summary();
        }
    });

    observer.observe(d.body, {
        childList: true,
        subtree: true
    });

    // 确保在 DOM 加载完成后执行
    if(d.readyState === 'loading') {
        d.addEventListener('DOMContentLoaded', () => setTimeout(summary, 0));
    } else {
        setTimeout(summary, 0);
    }
})();