Greasy Fork

Greasy Fork is available in English.

V2EX 文章总结助手

为 V2EX 帖子生成总结

当前为 2024-12-25 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         V2EX 文章总结助手
// @name:zh-CN   V2EX 文章总结助手
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  为 V2EX 帖子生成总结
// @description:zh-CN  为 V2EX 帖子生成总结
// @author       Jandaes
// @homepage     https://github.com/Jandaes/v2ex_ai/
// @supportURL   https://github.com/Jandaes/v2ex_ai/issues
// @match        https://www.v2ex.com/*
// @icon         https://www.v2ex.com/favicon.ico
// @grant        none
// @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 store={get:k=>JSON.parse(ls.getItem(k)||'{}'),set:(k,v)=>ls.setItem(k,JSON.stringify(v))};
    const defaultPrompt='只精简总结以下内容的核心要点、不需要加入你的任何观点';
    
    function modal(){
        const isDark=store.get('theme')==='dark'||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`
        });

        // 先将 modalContent 添加到 modal
        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">V2EX 文章总结助手设置</h3>
                <div style="display:flex;align-items:center;gap:8px">
                    <span style="font-size:14px">主题</span>
                    <select id="theme" style="padding:4px 8px">
                        <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}
            .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}
        `);
        
        // 先将 modal 添加到 body,这样后续的选择器才能找到元素
        d.body.appendChild(m);
        
        // 加载设置
        const settings=store.get('settings');
        $('#url',c).value=settings.apiUrl||'';
        $('#key',c).value=settings.apiKey||'';
        $('#model',c).value=settings.modelName||'';
        $('#prompt',c).value=settings.prompt||defaultPrompt;
        
        // 绑定事件
        $('.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('settings',{
                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(){
        const h=$('.header');
        if(!h)return;
        const g=$('.gray',h);
        if(!g||$('.summary-button',g))return;
        
        g.insertAdjacentText('beforeend',' ∙ ');
        const sum=createElement('a',{
            href:'javascript:void(0)',
            className:'tb summary-button',
            innerHTML:'总结 <span style="font-size:14px">✨</span>'
        });
        
        sum.onclick=async()=>{
            const content=getContent();
            if(!content)return;
            
            const container=getContainer();
            if(!container)return;
            
            const cont=$('.summary-content',container);
            const id=w.location.pathname.match(/\/t\/(\d+)/)?.[1];
            
            const saved=store.get('summaries')[id];
            if(saved){
                cont.innerHTML=saved;
                container.style.display='block';
                return;
            }
            
            cont.textContent='正在生成总结...';
            container.style.display='block';
            
            const sum=await request(content);
            if(sum){
                const s=store.get('summaries');
                s[id]=sum;
                store.set('summaries',s);
                cont.innerHTML=sum;
            }else{
                cont.textContent='生成总结失败,请检查设置和网络连接';
            }
        };
        
        g.appendChild(sum);
        g.insertAdjacentText('beforeend',' ∙ ');
        
        const set=createElement('a',{
            href:'javascript:void(0)',
            className:'tb settings-button',
            innerHTML:'设置 <span style="font-size:14px">⚙️</span>'
        });
        set.onclick=modal;
        g.appendChild(set);
    }

    function getContent(){
        const c=$('.topic_content');
        if(!c)return null;
        return c.textContent.trim();
    }

    function getContainer(){
        const h=$('.header');
        if(!h||$('.summary-container'))return $('.summary-container');
        
        const c=createElement('div',{
            className:'summary-container',
            style:`margin:10px 0;padding:15px;background:var(--box-background-color,#fff);border-radius:6px;font-size:14px;line-height:1.6;display:none;border:1px solid var(--box-border-color,#eee);box-shadow:0 2px 4px rgba(0,0,0,.05)`
        });
        
        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){
                const id=w.location.pathname.match(/\/t\/(\d+)/)?.[1];
                const s=store.get('summaries');
                s[id]=sum;
                store.set('summaries',s);
                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);
        
        h.parentNode.insertBefore(c,h.nextSibling);
        return c;
    }

    async function request(content){
        const s=store.get('settings');
        if(!s.apiUrl||!s.apiKey||!s.modelName){
            alert('请先完成设置(API URL、API Key 和模型名称为必填项)');
            return null;
        }
        
        try{
            const r=await fetch(s.apiUrl,{
                method:'POST',
                headers:{
                    'Authorization':`Bearer ${s.apiKey}`,
                    'Content-Type':'application/json',
                    'Accept':'*/*',
                    'Connection':'keep-alive'
                },
                body:JSON.stringify({
                    messages:[
                        {role:"system",content:s.prompt||defaultPrompt},
                        {role:"user",content}
                    ],
                    model:s.modelName,
                    stream:false
                })
            });
            
            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){
            alert(`请求失败: ${e.message}`);
            return null;
        }
    }

    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);
    }

    if(d.readyState==='loading')d.addEventListener('DOMContentLoaded',summary);
    else summary();
})();