伪物弹幕

此乃伪物。

// ==UserScript==
// @name         伪物弹幕
// @namespace    http://tampermonkey.net/
// @version      1.20
// @description  此乃伪物。
// @author       Yora
// @license      MIT
// @match        *://live.bilibili.com/*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      generativelanguage.googleapis.com
// @run-at       document-idle
// ==/UserScript==

(function() {
  'use strict';

  try {
  const DEFAULT = {
    n: 15,
    autoSend: false,
    apiKey: '',
    danmuInputSelector: 'textarea',
    danmuSendButtonSelector: 'button[data-send]',
    interval: 0,
    keywords: '',
    // 新增默认设置
    stealMode: false,
    focusMode: false,
    stealUids: '',
  };

  const DEFAULT_PROMPT = `背景:我正在一个直播间里,作为粉丝,请在生成弹幕时,完全融入弹幕氛围,保持简短且口语化的风格。避免使用正式、书面化或AI的表达,不要带感叹词,不要透露是AI。适度复读和互动,让回复像真实观众发出的弹幕。请根据以下弹幕列表,生成一句相关且自然的弹幕内容,不要带发言人或冒号,也不要多余解释。弹幕列表:
{danmu}`;

  GM_addStyle(`
    #llm-panel {
      position: fixed;
      right: 20px;
      bottom: 20px;
      width: 380px;
      background: #fff8f7;
      color: #3b1e1e;
      padding: 12px 16px;
      border-radius: 10px;
      font-size: 14px;
      font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
      box-shadow: 0 8px 20px rgba(255, 200, 160, 0.5);
      z-index: 9999999;
      user-select: none;
      cursor: default;
      transition: height 0.3s ease, width 0.3s ease, padding 0.3s ease;
      overflow: hidden;
    }
    #llm-panel header {
      font-weight: 700;
      font-size: 18px;
      margin-bottom: 8px;
      cursor: move;
      user-select: none;
      color: #a82f2f;
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding-right: 6px;
    }
    #llm-toggle-minimize {
      background: transparent;
      border: none;
      color: #a82f2f;
      font-size: 20px;
      cursor: pointer;
      user-select: none;
      line-height: 1;
      padding: 0 6px;
      flex-shrink: 0;
    }
    #llm-toggle-options {
      background: transparent;
      border: none;
      color: #a85a32;
      font-size: 13px;
      text-decoration: underline;
      cursor: pointer;
      margin-bottom: 10px;
      user-select: none;
      padding: 0;
    }
    #llm-options {
      display: none;
      margin-bottom: 10px;
    }
    #llm-options label {
      display: block;
      margin-top: 10px;
      font-weight: 600;
      cursor: pointer;
      user-select: none;
      color: #5f3b3b;
    }
    #llm-options input[type="number"],
    #llm-options input[type="text"],
    #llm-options textarea {
      width: 100%;
      padding: 8px 12px;
      margin-top: 6px;
      border-radius: 6px;
      border: 1px solid #f1c8c8;
      background: #fff1f0;
      color: #5f3b3b;
      font-size: 13px;
      box-sizing: border-box;
      transition: border-color 0.3s;
      user-select: text;
      font-family: monospace;
      resize: vertical;
    }
    #llm-options input[type="number"]:focus,
    #llm-options input[type="text"]:focus,
    #llm-options textarea:focus {
      border-color: #a84747;
      outline: none;
      background: #ffe5e5;
    }
    #llm-options input[type="checkbox"] {
      margin-right: 6px;
      cursor: pointer;
      vertical-align: middle;
      width: 18px;
      height: 18px;
      user-select: none;
    }
    #llm-buttons {
      margin-top: 6px;
      user-select: none;
    }
    #llm-buttons button {
      margin: 6px 10px 0 0;
      padding: 10px 18px;
      border: none;
      border-radius: 8px;
      background: #f9c06b;
      color: #5f3b3b;
      font-weight: 700;
      font-size: 14px;
      cursor: pointer;
      box-shadow: 0 4px 8px rgba(249,192,107,0.6);
      transition: background 0.3s, color 0.3s;
      user-select: none;
    }
    #llm-buttons button:hover:not(:disabled) {
      background: #fbcf83;
      color: #4b2c2c;
    }
    #llm-buttons button:disabled {
      cursor: not-allowed;
      opacity: 0.6;
      background: #d8b25e;
      color: #3c2e22;
      box-shadow: none;
    }
    #llm-buttons button.running {
      background: #c94b4b !important;
      color: #fff !important;
      box-shadow: 0 0 12px #c94b4bcc !important;
    }
    #llm-status {
      max-height: 150px;
      overflow-y: auto;
      background: #fff2f0;
      border-radius: 10px;
      padding: 12px;
      font-family: "Consolas", "Courier New", monospace;
      font-size: 13px;
      line-height: 1.4;
      color: #4a2929;
      white-space: pre-wrap;
      box-shadow: inset 0 0 10px #f9c06baa;
      user-select: text;
      transition: max-height 0.3s ease, overflow 0.3s ease;
    }
    /* 滚动条美化 */
    #llm-status::-webkit-scrollbar {
      width: 8px;
    }
    #llm-status::-webkit-scrollbar-track {
      background: transparent;
    }
    #llm-status::-webkit-scrollbar-thumb {
      background: #f9c06b;
      border-radius: 4px;
    }
  `);

  // 创建面板HTML,新增“定时触发”开关,和自动发送同级
  const panel = document.createElement('div');
  panel.id = 'llm-panel';
  panel.innerHTML = `
    <header id="llm-header">
      伪物弹幕
      <button id="llm-toggle-minimize" title="缩小/展开">—</button>
    </header>
    <div id="llm-content">
      <button id="llm-toggle-options">展开更多选项 ▼</button>
      <div id="llm-options">
        <label for="llm-n">参考弹幕数量 (n):
          <input id="llm-n" type="number" min="1" max="100" />
        </label>
        <label for="llm-apikey">API Key:
          <input id="llm-apikey" type="text" placeholder="AIza..." autocomplete="off" />
        </label>
        <label><input id="llm-autosend" type="checkbox" /> 自动发送弹幕</label>

        <!-- 新增:偷子模式 & 专注模式 -->
        <label><input id="llm-stealmode" type="checkbox" /> 偷子模式</label>
        <div id="llm-focus-row" style="display:none; margin-left:18px;">
          <label><input id="llm-focusmode" type="checkbox" /> 专注模式</label>
        </div>
        <label id="llm-steal-uids-label" style="display:none;">优先偷取 UID 列表(逗号分隔):
          <input id="llm-steal-uids" type="text" placeholder="14999357,114514" />
        </label>

        <label><input id="llm-timer" type="checkbox" /> 定时触发</label>
        <label for="llm-interval">定时间隔(秒):
          <input id="llm-interval" type="number" min="1" max="3600" />
        </label>
        <label for="llm-keywords">关键词触发(逗号分隔):
          <input id="llm-keywords" type="text" placeholder="关键字1,关键字2,关键字3" />
        </label>
        <label for="llm-customprompt">自定义提示词(支持使用{danmu}作为弹幕列表占位符):
          <textarea id="llm-customprompt" rows="10" placeholder="请输入自定义提示词,{danmu} 将替换为弹幕列表"></textarea>
        </label>
      </div>
      <div id="llm-buttons">
        <button id="llm-run">仿写弹幕</button>
        <button id="llm-save" style="display:none;">保存配置</button>
        <button id="llm-showdanmu">弹幕列表</button>
      </div>
    </div>
    </br>
    <div id="llm-status">状态信息将在这里显示</div>
  `;
  document.body.appendChild(panel);

  // 折叠展开更多选项(不动)
  const toggleBtn = document.getElementById('llm-toggle-options');
  const optionsDiv = document.getElementById('llm-options');
  toggleBtn.addEventListener('click', () => {
    if (optionsDiv.style.display === 'none' || optionsDiv.style.display === '') {
      optionsDiv.style.display = 'block';
      toggleBtn.textContent = '收起更多选项 ▲';
    } else {
      optionsDiv.style.display = 'none';
      toggleBtn.textContent = '展开更多选项 ▼';
    }
  });
  optionsDiv.style.display = 'none';

  // 拖动逻辑(保持不变)
  const header = document.getElementById('llm-header');
  const panelElement = document.getElementById('llm-panel');
  let isDragging = false;
  let dragStartX = 0;
  let dragStartY = 0;
  let panelStartRight = 0;
  let panelStartBottom = 0;

  header.addEventListener('mousedown', e => {
    if (e.target.id === 'llm-toggle-minimize') return;
    isDragging = true;
    dragStartX = e.clientX;
    dragStartY = e.clientY;
    const rect = panelElement.getBoundingClientRect();
    panelStartRight = window.innerWidth - rect.right;
    panelStartBottom = window.innerHeight - rect.bottom;
    document.body.style.userSelect = 'none';
  });
  document.addEventListener('mouseup', () => {
    isDragging = false;
    document.body.style.userSelect = '';
  });
  document.addEventListener('mousemove', e => {
    if (!isDragging) return;
    const dx = e.clientX - dragStartX;
    const dy = e.clientY - dragStartY;
    panelElement.style.right = (panelStartRight - dx) + 'px';
    panelElement.style.bottom = (panelStartBottom - dy) + 'px';
  });

  // 缩小展开按钮(保持你原来逻辑不变)
  const llmContent = document.getElementById('llm-content');
  const toggleMinBtn = document.getElementById('llm-toggle-minimize');
  const statusDiv = document.getElementById('llm-status');

  let minimized = false;
  function setMinimized(state) {
    minimized = state;
    if (minimized) {
      llmContent.style.display = 'none';
      statusDiv.style.maxHeight = '40px';
      statusDiv.style.overflow = 'hidden';
      toggleMinBtn.textContent = '+';
      panelElement.style.height = 'auto';
      panelElement.style.width = '280px';
      panelElement.style.padding = '8px 12px';
    } else {
      llmContent.style.display = 'block';
      statusDiv.style.maxHeight = '150px';
      statusDiv.style.overflowY = 'auto';
      toggleMinBtn.textContent = '—';
      panelElement.style.height = 'auto';
      panelElement.style.width = '380px';
      panelElement.style.padding = '12px 16px';
    }
  }
  toggleMinBtn.addEventListener('click', e => {
    e.stopPropagation();
    setMinimized(!minimized);
  });
  setMinimized(false);

  // 状态更新函数
  function updateStatus(text) {
    statusDiv.textContent = text;
  }

  // 定时相关变量
  let timerIsRunning = false;
  let intervalId = null;

  // 新增倒计时变量
  let countdownTimerId = null;
  let nextTriggerTime = 0;

  // 获取元素
  const timerCheckbox = document.getElementById('llm-timer');
  const runBtn = document.getElementById('llm-run');
  const intervalInput = document.getElementById('llm-interval');
  const autoSendCheckbox = document.getElementById('llm-autosend');

  // 新增元素引用
  const stealCheckbox = document.getElementById('llm-stealmode');
  const focusRow = document.getElementById('llm-focus-row');
  const focusCheckbox = document.getElementById('llm-focusmode');
  const stealUidsLabel = document.getElementById('llm-steal-uids-label');
  const stealUidsInput = document.getElementById('llm-steal-uids');

  // === 以下是你已有的调用接口函数,保持不变 ===
  async function collectRecentDanmu() {
    const n = Number(await GM_getValue('n', DEFAULT.n));
    const nodes = Array.from(document.querySelectorAll('.chat-item.danmaku-item'));
    return nodes.slice(-n).map(el => {
      const danmuSpan = el.querySelector('span.danmaku-item-right');
      return danmuSpan ? danmuSpan.textContent.trim() : '';
    }).filter(Boolean);
  }

  // 新增:按对象返回最近弹幕(包含 uid 与 text),不替换原有函数
  async function collectRecentDanmuObjs() {
    const n = Number(await GM_getValue('n', DEFAULT.n));
    const nodes = Array.from(document.querySelectorAll('.chat-item.danmaku-item'));
    const sliced = nodes.slice(-n);
    return sliced.map(el => {
      const danmuSpan = el.querySelector('span.danmaku-item-right');
      const text = danmuSpan ? danmuSpan.textContent.trim() : '';
      const uid = el.getAttribute('data-uid') || '';
      return { uid: uid, text: text, el: el };
    }).filter(o => o.text);
  }

  function callGemini(prompt) {
    return new Promise(async (resolve, reject) => {
      const apiKey = await GM_getValue('apiKey', DEFAULT.apiKey);
      if (!apiKey) return reject(new Error('未设置 API Key'));

      const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;

      const bodyObj = {
        contents: [
          {
            parts: [
              { text: prompt }
            ]
          }
        ],
        generationConfig: {
          temperature: 0.7,
          maxOutputTokens: 5000
        }
      };

      const body = JSON.stringify(bodyObj);

      GM_xmlhttpRequest({
        method: 'POST',
        url,
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
          'User-Agent': 'Mozilla/5.0'
        },
        data: body,
        onload: res => {
          try {
            const data = JSON.parse(res.responseText);
            if (data?.error) {
              return reject(new Error(`API 错误: ${data.error.message}`));
            }
            let output = '';
            if (data?.candidates?.length) {
              const parts = data.candidates[0].content?.parts;
              if (parts && parts.length) {
                output = parts.map(p => p.text).join('');
              }
            }
            if (!output) {
              return resolve('[模型未返回内容,可能因安全策略或无法理解当前弹幕]');
            }
            resolve(output);
          } catch (e) {
            reject(new Error(`解析响应失败: ${e.message}`));
          }
        },
        onerror: err => reject(new Error(`网络请求失败: ${err.message}`))
      });
    });
  }

  async function fillAndMaybeSend(text) {
    if (!text || text.includes('[模型未返回内容')) return;

    const input = document.querySelector(DEFAULT.danmuInputSelector);
    if (!input) return;

    input.focus();
    input.value = text;
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.dispatchEvent(new Event('change', { bubbles: true }));

    if (await GM_getValue('autoSend', DEFAULT.autoSend)) {
      // 等 100 毫秒再点击发送按钮,确保输入事件完成
      await new Promise(r => setTimeout(r, 100));

      const btn = Array.from(document.querySelectorAll('button'))
        .find(b => b.textContent.trim() === '发送' && !b.disabled);

      if (btn) {
        btn.click();
      } else {
        updateStatus('未找到发送按钮');
      }
    }
  }

  // 新增:偷子模式的触发逻辑
  async function triggerStealMode() {
    // 获取最近弹幕对象
    const objs = await collectRecentDanmuObjs();
    if (!objs || objs.length === 0) {
      updateStatus('未找到弹幕(偷子模式)');
      return;
    }
    // 如果专注模式且有 UID 列表则按 UID 顺序优先取
    const focus = await GM_getValue('focusMode', DEFAULT.focusMode);
    const uidStr = (await GM_getValue('stealUids', DEFAULT.stealUids)) || stealUidsInput.value || '';
    const uidList = uidStr.split(',').map(s => s.trim()).filter(Boolean);

    let chosen = null;
    if (focus && uidList.length > 0) {
      // 按 uidList 的顺序,找该 uid 的最新弹幕(从后往前遍历)
      for (let uid of uidList) {
        for (let i = objs.length - 1; i >= 0; i--) {
          if (objs[i].uid === uid) {
            chosen = objs[i];
            break;
          }
        }
        if (chosen) break;
      }
    }
    if (!chosen) {
      // 随机取一个(从 objs 中随机选择)
      const idx = Math.floor(Math.random() * objs.length);
      chosen = objs[idx];
    }
    if (chosen && chosen.text) {
      await fillAndMaybeSend(chosen.text);
      updateStatus('偷子模式已发送(来源 uid:' + (chosen.uid || 'unknown') + '):\n' + chosen.text);
    } else {
      updateStatus('未能选到合适弹幕(偷子模式)');
    }
  }

  async function triggerCallGemini() {
    updateStatus('调用接口中...');
    const danmuList = await collectRecentDanmu();
    if (danmuList.length === 0) {
      updateStatus('未找到弹幕');
      return;
    }

    // 如果开启偷子模式则走偷子逻辑(不请求 LLM)
    const steal = await GM_getValue('stealMode', DEFAULT.stealMode);
    if (steal) {
      await triggerStealMode();
      return;
    }

    let customPromptTemplate = await GM_getValue('customPrompt', '');
    if (!customPromptTemplate) customPromptTemplate = DEFAULT_PROMPT;

    const prompt = customPromptTemplate.replace(/\{danmu\}/g, danmuList.join('\n'));

    try {
      const respText = await callGemini(prompt);
      await fillAndMaybeSend(respText);
      updateStatus('完成,模型回复:\n' + respText);
    } catch (e) {
      updateStatus('请求失败:' + e.message);
    }
  }
  // === 调用接口函数结束 ===

  // 倒计时更新函数
  function updateCountdown() {
    if (!timerIsRunning) return;
    const now = Date.now();
    let remain = Math.floor((nextTriggerTime - now) / 1000);
    if (remain < 0) remain = 0;
    updateStatus(`距离下次定时触发还有 ${remain} 秒`);
    if (remain <= 0) {
      clearInterval(countdownTimerId);
      countdownTimerId = null;
    }
  }

  function updateRunButton() {
    if (!timerCheckbox.checked) {
      runBtn.textContent = '仿写弹幕';
      runBtn.classList.remove('running');
      timerIsRunning = false;
      if (intervalId) {
        clearInterval(intervalId);
        intervalId = null;
      }
      if (countdownTimerId) {
        clearInterval(countdownTimerId);
        countdownTimerId = null;
      }
      updateStatus('定时触发已关闭');
    } else {
      runBtn.textContent = '开始定时触发';
      runBtn.classList.remove('running');
      timerIsRunning = false;
      if (intervalId) {
        clearInterval(intervalId);
        intervalId = null;
      }
      if (countdownTimerId) {
        clearInterval(countdownTimerId);
        countdownTimerId = null;
      }
      updateStatus('定时触发未运行');
    }
  }

  // 点击“仿写弹幕”或“开始/停止定时触发”按钮
  runBtn.addEventListener('click', async () => {
    if (timerCheckbox.checked) {
      // 定时模式开关开启,点击为启动/停止定时触发
      if (timerIsRunning) {
        // 停止
        timerIsRunning = false;
        runBtn.textContent = '开始定时触发';
        runBtn.classList.remove('running');
        if (intervalId) {
          clearInterval(intervalId);
          intervalId = null;
        }
        if (countdownTimerId) {
          clearInterval(countdownTimerId);
          countdownTimerId = null;
        }
        updateStatus('已停止定时触发');
      } else {
        // 开始定时触发
        const intervalSeconds = parseInt(intervalInput.value, 10);
        if (isNaN(intervalSeconds) || intervalSeconds <= 0) {
          alert('请填写有效的定时间隔(秒)');
          return;
        }
        timerIsRunning = true;
        runBtn.textContent = '停止定时触发';
        runBtn.classList.add('running');
        nextTriggerTime = Date.now() + intervalSeconds * 1000;

        // 先立即触发一次
        try {
          await triggerCallGemini();
        } catch (e) {
          updateStatus('触发失败:' + e.message);
        }
        // 设置定时器周期触发
        intervalId = setInterval(async () => {
          nextTriggerTime = Date.now() + intervalSeconds * 1000;
          try {
            // 先检查关键词触发(如果有设置)
            const keywordsStr = document.getElementById('llm-keywords').value.trim();
            if (keywordsStr) {
              const keywords = keywordsStr.split(',').map(s => s.trim()).filter(Boolean);
              const danmus = await collectRecentDanmu();
              const danmuText = danmus.join(' ');
              // 任意关键词存在则触发
              const matched = keywords.some(k => danmuText.includes(k));
              if (!matched) {
                updateStatus(`定时触发跳过(无匹配关键词)`);
                return; // 跳过本次触发
              }
            }
            // 如果偷子模式开启,triggerCallGemini 内部会处理
            await triggerCallGemini();
          } catch (e) {
            updateStatus('定时触发失败:' + e.message);
          }
        }, intervalSeconds * 1000);

        // 启动倒计时显示
        if (countdownTimerId) clearInterval(countdownTimerId);
        countdownTimerId = setInterval(updateCountdown, 1000);
      }
    } else {
      // 非定时模式,直接单次触发
      runBtn.disabled = true;
      updateStatus('调用中...');
      try {
        await triggerCallGemini();
      } catch (e) {
        updateStatus('调用失败:' + e.message);
      }
      runBtn.disabled = false;
    }
  });

  // 读取配置初始化控件值
  (async () => {
    document.getElementById('llm-n').value = await GM_getValue('n', DEFAULT.n);
    document.getElementById('llm-apikey').value = await GM_getValue('apiKey', DEFAULT.apiKey);
    document.getElementById('llm-autosend').checked = await GM_getValue('autoSend', DEFAULT.autoSend);
    document.getElementById('llm-interval').value = await GM_getValue('interval', 0);
    document.getElementById('llm-keywords').value = await GM_getValue('keywords', '');
    document.getElementById('llm-customprompt').value = await GM_getValue('customPrompt', DEFAULT_PROMPT);

    // 读取新增设置
    const stealVal = await GM_getValue('stealMode', DEFAULT.stealMode);
    stealCheckbox.checked = !!stealVal;
    const focusVal = await GM_getValue('focusMode', DEFAULT.focusMode);
    focusCheckbox.checked = !!focusVal;
    document.getElementById('llm-steal-uids').value = await GM_getValue('stealUids', DEFAULT.stealUids);

    // 根据偷子模式显示专注行
    if (stealCheckbox.checked) {
      focusRow.style.display = 'block';
      stealUidsLabel.style.display = 'block';
    } else {
      focusRow.style.display = 'none';
      stealUidsLabel.style.display = 'none';
    }

    timerCheckbox.checked = false;
    updateRunButton();
  })();

  // 监听输入框变化并保存到GM存储
  document.getElementById('llm-n').addEventListener('change', e => {
    const val = parseInt(e.target.value, 10);
    if (val > 0 && val <= 100) GM_setValue('n', val);
  });
  document.getElementById('llm-apikey').addEventListener('change', e => {
    GM_setValue('apiKey', e.target.value.trim());
  });
  document.getElementById('llm-autosend').addEventListener('change', e => {
    GM_setValue('autoSend', e.target.checked);
  });
  intervalInput.addEventListener('change', e => {
    const val = parseInt(e.target.value, 10);
    if (val > 0 && val <= 3600) GM_setValue('interval', val);
  });
  document.getElementById('llm-keywords').addEventListener('change', e => {
    GM_setValue('keywords', e.target.value.trim());
  });
  document.getElementById('llm-customprompt').addEventListener('change', e => {
    GM_setValue('customPrompt', e.target.value);
  });

  // 新增:偷子与专注设置保存与展示联动
  stealCheckbox.addEventListener('change', async (e) => {
    const checked = e.target.checked;
    await GM_setValue('stealMode', checked);
    if (checked) {
      focusRow.style.display = 'block';
      stealUidsLabel.style.display = 'block';
    } else {
      focusRow.style.display = 'none';
      stealUidsLabel.style.display = 'none';
    }
  });
  focusCheckbox.addEventListener('change', async (e) => {
    const checked = e.target.checked;
    await GM_setValue('focusMode', checked);
  });
  stealUidsInput.addEventListener('change', async (e) => {
    await GM_setValue('stealUids', e.target.value.trim());
  });

  timerCheckbox.addEventListener('change', e => {
    updateRunButton();
  });

  // “弹幕列表”按钮,显示最近弹幕
  document.getElementById('llm-showdanmu').addEventListener('click', async () => {
    const list = await collectRecentDanmu();
    if (list.length === 0) {
      alert('未找到弹幕');
    } else {
      alert('最近弹幕(最新在最后):\n\n' + list.join('\n'));
    }
  });

  // “保存配置”按钮隐藏,保持界面简洁,不再显示
  document.getElementById('llm-save').style.display = 'none';

  } catch (outerErr) {
    // 全局保护,防止脚本因错误中断
    console.error('伪物弹幕脚本发生未捕获错误:', outerErr);
    try { // 尝试简单展示到面板(若面板已创建)
      const s = document.getElementById('llm-status');
      if (s) s.textContent = '脚本错误:' + outerErr.message;
    } catch (e) {}
  }

})();