// ==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);
})();