Greasy Fork

V2EX 文章总结助手

为 V2EX 帖子生成总结

// ==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     https://greasyfork.org/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);
    }
})();