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