9/20/2026
reddit回复助手
// ==UserScript==
// @name Reddit EV Parts 回复助手 (ZOHEN)
// @namespace zohen.reddit.assistant
// @version 0.1.1
// @description Reddit 帖子详情页右侧半透明悬浮窗:自动把标题+正文翻译成中文;一键构造有深度的英文回复,可选是否植入 ZOHEN 软广告
// @author simon
// @match https://*.reddit.com/r/*/comments/*
// @match https://reddit.com/r/*/comments/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect 127.0.0.1
// @connect localhost
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ---------- 配置(GM 存储,菜单里可改) ----------
const DEFAULTS = {
apiBase: 'http://127.0.0.1:8080/v1', // OpenAI 兼容接口,末尾不带 /
apiKey: 'none', // 本地 llama.cpp 随便填
model: 'qwen3.8-27b',
brand: 'ZOHEN (zohencar.com)', // 软广告植入的品牌名
};
const getCfg = (k) => GM_getValue('cfg_' + k, DEFAULTS[k]);
const setCfg = (k, v) => GM_setValue('cfg_' + k, v);
GM_registerMenuCommand('⚙️ 设置 API(端点/模型/Key/品牌)', () => {
const apiBase = prompt('API Base(OpenAI 兼容,不带末尾 /)', getCfg('apiBase'));
if (apiBase === null) return;
const model = prompt('模型名', getCfg('model'));
if (model === null) return;
const apiKey = prompt('API Key', getCfg('apiKey'));
if (apiKey === null) return;
const brand = prompt('软广告品牌名', getCfg('brand'));
if (brand === null) return;
setCfg('apiBase', apiBase.trim());
setCfg('model', model.trim());
setCfg('apiKey', apiKey.trim());
setCfg('brand', brand.trim());
alert('已保存,刷新页面后生效。');
});
// ---------- LLM 调用(OpenAI 兼容 /chat/completions) ----------
function chat(messages, maxTokens = 1600) {
const base = getCfg('apiBase').replace(/\/+$/, '');
const url = base + '/chat/completions';
console.log('[ZOHEN] 实际请求 API:', url);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + getCfg('apiKey'),
},
data: JSON.stringify({
model: getCfg('model'),
messages,
temperature: 0.7,
max_tokens: maxTokens,
}),
timeout: 300000,
onload: (res) => {
const raw = res.responseText || '';
let j = null;
try {
j = JSON.parse(raw);
} catch (_) {
// 不是 JSON,下面按状态码处理
}
if (res.status < 200 || res.status >= 300) {
return reject(new Error(
`接口 HTTP ${res.status}:${(j?.error?.message || raw)}`
));
}
if (!j) {
return reject(new Error(`接口返回非 JSON:${raw.slice(0, 500)}`));
}
if (j.error) {
return reject(new Error(
`接口错误:${j.error.message || JSON.stringify(j.error)}`
));
}
const content = j?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
return reject(new Error(`接口未返回有效内容:${raw.slice(0, 500)}`));
}
resolve(content.trim());
},
onerror: (res) => {
reject(new Error(
`网络错误:${res?.error || res?.statusText || ('无法连接 ' + url)}`
));
},
ontimeout: () => reject(new Error('请求超时')),
onabort: () => reject(new Error('请求被浏览器/扩展拦截')),
});
});
}
// ---------- 抽取帖子内容 ----------
function extractPost() {
const postEl = document.querySelector('shreddit-post');
const title =
postEl?.getAttribute('post-title')?.trim() ||
document.querySelector('shreddit-title')?.textContent?.trim() ||
document.querySelector('[data-testid="post-title"]')?.innerText?.trim() ||
document.querySelector('h1')?.innerText?.trim() ||
document.title;
// 新版 shreddit / 旧版 DOM 都试一遍
const bodyEl =
document.querySelector('shreddit-post [slot="text-body"]') ||
document.querySelector('[data-test-id="post-content"]') ||
document.querySelector('.Post .RichTextJSON-root') ||
document.querySelector('shreddit-post .md');
const body = bodyEl ? bodyEl.innerText.trim() : '';
const sub = location.pathname.match(/\/r\/([^/]+)/)?.[1] || '';
return { title, body, sub };
}
// Reddit 自身返回拦截页时,不要再把它当帖子内容翻译
function isRedditBlockedPage() {
const title = document.title || '';
const text = (document.body?.innerText || '').slice(0, 5000);
return /you'?ve been blocked|blocked by network security|whoa there,? stranger|access denied|too many requests/i
.test(title + '\n' + text);
}
function getComments(maxN = 15) {
return [...document.querySelectorAll('shreddit-comment .md, .Comment .RichTextJSON-root')]
.slice(0, maxN)
.map((el) => el.innerText.trim())
.filter((t) => t.length > 20);
}
// ---------- 提示词 ----------
function translatePrompt(post) {
return [
{ role: 'system', content: '你是专业英中翻译。把 Reddit 帖子忠实翻译成自然流畅的简体中文,保留原意与语气,不要解释,不要添加内容。' },
{ role: 'user', content: `子版块: r/${post.sub}\n标题: ${post.title}\n\n正文:\n${post.body || '(无正文)'}` },
];
}
function zhDraftPrompt(post, comments, withAd) {
const adRule = withAd
? `其中可以自然地、以真实用户口吻提及品牌 ${getCfg('brand')}(电动汽车替换零配件供应商)作为个人使用经验或顺带推荐。软性植入:只出现一次,融入上下文,禁止夸张营销话术,禁止像广告。`
: '完全不要提及任何品牌或产品,不出现任何营销内容,只做纯粹的真诚讨论。';
return [
{
role: 'system',
content:
'你是 Reddit 汽车/电动车社区的一名资深参与者,正在给一篇英文帖子写回复。' +
'请用【简体中文】输出回复草稿(之后会由人工润色再翻译成英文)。' +
'硬性要求:最终英文版要控制在 100 个单词以内,所以你的中文草稿务必短小精悍(大约 150 字以内),宁短勿长。' +
'风格要求:像美国 Reddit 老哥随手回帖——口语化、随意、有梗可以带点俚语味;' +
'注意留白,不要面面俱到、不要把话说满,一两句点到即止反而更真实;' +
'可以偶尔放一两个表情符号(emoji),就像平时打字聊天那样,但全文最多 1-2 个,放在语气自然的地方,不要堆砌;' +
'内容仍要有干货(具体细节/亲身经历/明确观点),但用最少的字说出来。' +
'不要小标题,不要"作为AI"之类的表述,不要客套开头结尾。' +
adRule,
},
{
role: 'user',
content:
`子版块: r/${post.sub}\n帖子标题: ${post.title}\n\n帖子正文:\n${post.body || '(无正文)'}\n\n` +
(comments.length ? `热门评论:\n- ${comments.slice(0, 8).join('\n- ')}\n\n` : '') +
'请为这篇帖子写一条高质量回复草稿(中文)。只输出回复内容本身。',
},
];
}
// Reddit 是 SPA,正文可能比面板晚渲染出来,这里轮询等待后再翻译
async function autoTranslate(panel) {
const box = panel.querySelector('#zp-translation');
const statusEl = panel.querySelector('#zp-status');
try {
let post = null;
// 最多等 10 秒让正文出现(链接帖没有正文,所以第 5 次后即使无正文也继续)
for (let i = 0; i < 20; i++) {
if (isRedditBlockedPage()) {
box.textContent = '⚠️ 当前 Reddit 页面是 Blocked/拦截页,不是插件在请求 Reddit。请直接打开该 URL 确认。';
statusEl.textContent = '❌ 页面被 Reddit 拦截';
return;
}
post = extractPost();
if (post.title && (post.body || i >= 4)) break;
await new Promise((r) => setTimeout(r, 500));
}
if (!post || !post.title) {
throw new Error('未找到帖子标题,Reddit DOM 可能已变化或页面未加载完成');
}
const zh = await chat(translatePrompt(post), 1200);
box.textContent = zh;
statusEl.textContent = '✅ 翻译完成';
} catch (e) {
box.textContent = '❌ 翻译失败:' + e.message;
statusEl.textContent = '❌ 翻译失败';
}
}
// ---------- 悬浮窗 UI ----------
function buildPanel() {
if (document.getElementById('zohen-panel')) return;
const panel = document.createElement('div');
panel.id = 'zohen-panel';
Object.assign(panel.style, {
position: 'fixed', top: '70px', right: '12px', width: '380px',
maxHeight: '82vh', zIndex: 99999,
background: 'rgba(20, 22, 30, 0.86)', backdropFilter: 'blur(8px)',
color: '#e8eaf0', borderRadius: '12px',
boxShadow: '0 4px 24px rgba(0,0,0,0.45)',
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
display: 'flex', flexDirection: 'column',
});
panel.innerHTML = `
<div id="zp-head" style="display:flex;align-items:center;padding:8px 12px;cursor:move;user-select:none;border-bottom:1px solid rgba(255,255,255,0.12);">
<b style="flex:1">🚗 ZOHEN 回复助手</b>
<span id="zp-min" style="cursor:pointer;padding:0 6px;opacity:.7">—</span>
</div>
<div id="zp-body" style="padding:10px 12px;overflow-y:auto;display:flex;flex-direction:column;gap:8px;">
<div id="zp-translation" style="white-space:pre-wrap;line-height:1.6;background:rgba(255,255,255,0.06);padding:8px;border-radius:8px;max-height:300px;min-height:80px;overflow-y:auto;overflow-x:hidden;word-break:break-word;overflow-wrap:anywhere;">翻译中…</div>
<label style="display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="zp-ad"> 加入软广告(${getCfg('brand')})
</label>
<button id="zp-gen" style="padding:8px;background:#2e7d32;color:#fff;border:0;border-radius:8px;cursor:pointer;font-weight:bold">✍️ 构造中文回复</button>
<button id="zp-to-en" style="display:none;padding:8px;background:#1565c0;color:#fff;border:0;border-radius:8px;cursor:pointer;font-weight:bold">🌍 按上面的修改生成英文回复</button>
<div id="zp-status" style="color:#9aa4b8;min-height:16px;"></div>
<div id="zp-zh-wrap" style="display:none;flex-direction:column;gap:6px;">
<div style="opacity:.7">中文草稿(可直接修改):</div>
<div id="zp-reply-zh" contenteditable="true" spellcheck="false"
style="white-space:pre-wrap;line-height:1.6;background:rgba(255,255,255,0.06);padding:10px;border-radius:8px;outline:none;min-height:60px;"></div>
</div>
<div id="zp-reply-wrap" style="display:none;flex-direction:column;gap:6px;">
<div style="opacity:.7">英文回复(可直接修改后复制):</div>
<div id="zp-reply" contenteditable="true" spellcheck="false"
style="white-space:pre-wrap;line-height:1.6;background:rgba(255,255,255,0.06);padding:10px;border-radius:8px;outline:none;min-height:60px;"></div>
<button id="zp-copy" style="padding:6px;background:rgba(255,255,255,0.12);color:#fff;border:0;border-radius:8px;cursor:pointer">📋 复制回复</button>
</div>
</div>`;
document.body.appendChild(panel);
// 折叠
let collapsed = false;
panel.querySelector('#zp-min').onclick = () => {
collapsed = !collapsed;
panel.querySelector('#zp-body').style.display = collapsed ? 'none' : 'flex';
panel.querySelector('#zp-min').textContent = collapsed ? '+' : '—';
};
// 拖拽
const head = panel.querySelector('#zp-head');
head.onmousedown = (e) => {
if (e.target.id === 'zp-min') return;
const sx = e.clientX - panel.offsetLeft, sy = e.clientY - panel.offsetTop;
const move = (ev) => {
panel.style.left = (ev.clientX - sx) + 'px';
panel.style.top = (ev.clientY - sy) + 'px';
panel.style.right = 'auto';
};
const up = () => { removeEventListener('mousemove', move); removeEventListener('mouseup', up); };
addEventListener('mousemove', move); addEventListener('mouseup', up);
};
const status = (msg, color) => {
const el = panel.querySelector('#zp-status');
el.textContent = msg; el.style.color = color || '#9aa4b8';
};
// 自动翻译:面板建好即调用模型
autoTranslate(panel);
// 构造中文回复
panel.querySelector('#zp-gen').onclick = async function () {
const withAd = panel.querySelector('#zp-ad').checked;
this.disabled = true;
status(withAd ? '生成中文草稿(含软广告)…' : '生成中文草稿(纯讨论)…');
try {
const post = extractPost();
const zh = await chat(zhDraftPrompt(post, getComments(), withAd));
panel.querySelector('#zp-reply-zh').textContent = zh;
panel.querySelector('#zp-zh-wrap').style.display = 'flex';
panel.querySelector('#zp-to-en').style.display = 'block';
status('✅ 中文草稿已生成,请在上方修改润色,满意后点下面按钮生成英文版');
} catch (e) { status('❌ ' + e.message, '#ff8a80'); }
this.disabled = false;
};
// 中文草稿 → 英文回复
panel.querySelector('#zp-to-en').onclick = async function () {
const zhDraft = panel.querySelector('#zp-reply-zh').innerText.trim();
if (!zhDraft) return status('请先生成或填写中文草稿', '#ff8a80');
this.disabled = true;
status('翻译成英文中…');
try {
const en = await chat([
{
role: 'system',
content:
'You are translating a Chinese Reddit reply draft into natural, native-sounding English. ' +
'Hard limit: the final English reply must be at most ~100 words. Keep it tight; cut filler, never pad. ' +
'Style: casual American Reddit voice — contractions, everyday slang and colloquial phrasing ' +
'(e.g. "honestly", "tbh", "yours truly" type flavor where it fits), short sentences, natural rhythm. ' +
'Leave breathing room: do not over-explain or say everything — a slightly understated, offhand tone reads as real. ' +
'Emoji: keep any emoji from the draft, or you may add one where it fits — max 1-2 total, casual text-message style, never a row of them. ' +
'Keep the meaning, tone and any brand mention exactly as in the draft. No headings. ' +
'Output only the English reply.',
},
{ role: 'user', content: zhDraft },
]);
const wrap = panel.querySelector('#zp-reply-wrap');
panel.querySelector('#zp-reply').textContent = en;
wrap.style.display = 'flex';
status('✅ 英文版已生成,可编辑后复制');
} catch (e) { status('❌ ' + e.message, '#ff8a80'); }
this.disabled = false;
};
// 复制
panel.querySelector('#zp-copy').onclick = function () {
const text = panel.querySelector('#zp-reply').innerText;
navigator.clipboard.writeText(text).then(() => {
this.textContent = '✅ 已复制'; setTimeout(() => (this.textContent = '📋 复制回复'), 1500);
});
};
}
// Reddit 是 SPA,路径变化时重建
let lastUrl = '';
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
document.getElementById('zohen-panel')?.remove();
if (/\/r\/[^/]+\/comments\//.test(location.pathname)) buildPanel();
}
}, 800);
if (/\/r\/[^/]+\/comments\//.test(location.pathname)) buildPanel();
})();