bilibili 直播间独轮车 LAPLACE ver.

这是 bilibili 直播间简易版独轮车,基于 quiet/thusiant cmd 版本 https://greasyfork.org/scripts/421507 继续维护而来

// ==UserScript==
// @name         bilibili 直播间独轮车 LAPLACE ver.
// @namespace    https://greasyfork.org/users/9967
// @version      1.2.6
// @description  这是 bilibili 直播间简易版独轮车,基于 quiet/thusiant cmd 版本 https://greasyfork.org/scripts/421507 继续维护而来
// @author       sparanoid
// @license      AGPL
// @match        *://live.bilibili.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// ==/UserScript==

let MsgTemplates = GM_getValue('MsgTemplates', []);
let activeTemplateIndex = GM_getValue('activeTemplateIndex', 0);
const scriptInitVal = { msgSendInterval: 1, maxLength: 20, maxLogLines: 1000, randomColor: false, randomInterval: false };
for (let initVal in scriptInitVal) {
  if (GM_getValue(initVal) === undefined) GM_setValue(initVal, scriptInitVal[initVal]);
}

let sendMsg = false;

function getGraphemes(str) {
  const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
  return Array.from(segmenter.segment(str), ({ segment }) => segment);
}

function trimText(text, maxLength) {
  if (!text) return [text];

  const graphemes = getGraphemes(text);
  if (graphemes.length <= maxLength) return [text];

  const parts = [];
  let currentPart = [];
  let currentLength = 0;

  for (const char of graphemes) {
    if (currentLength >= maxLength) {
      parts.push(currentPart.join(''));
      currentPart = [char];
      currentLength = 1;
    } else {
      currentPart.push(char);
      currentLength++;
    }
  }

  if (currentPart.length > 0) {
    parts.push(currentPart.join(''));
  }

  return parts;
}

function appendToLimitedLog(logElement, message, maxLines) {
  const lines = logElement.value.split('\n');
  if (lines.length >= maxLines) {
    // Keep only the last (maxLines - 1) lines and add the new message
    lines.splice(0, lines.length - maxLines + 1);
  }
  lines.push(message);
  logElement.value = lines.join('\n');
  logElement.scrollTop = logElement.scrollHeight;
}

function extractRoomNumber(url) {
  const urlObj = new URL(url);
  const pathSegments = urlObj.pathname.split('/').filter(segment => segment !== '');
  const roomNumber = pathSegments.find(segment => Number.isInteger(Number(segment)));
  return roomNumber;
}

function processMessages(text, maxLength) {
  return text
    .split('\n')
    .map(line => trimText(line, maxLength))
    .flat()
    .filter(line => line && line.trim());
}

(function () {
  const check = setInterval(() => {
    const toggleBtn = document.createElement('div');
    toggleBtn.id = 'toggleBtn';
    toggleBtn.textContent = '独轮车面版';
    toggleBtn.style.cssText = `
      position: fixed;
      right: 14px;
      bottom: 14px;
      z-index: 2147483647;
      cursor: pointer;
      background: #777;
      color: white;
      padding: 6px 8px;
      border-radius: 4px;
      user-select: none;
    `;
    document.body.appendChild(toggleBtn);

    const list = document.createElement('div');
    list.style.cssText = `
      position: fixed;
      right: 14px;
      bottom: calc(14px + 30px);
      z-index: 2147483647;
      background: white;
      display: none;
      padding: 14px;
      box-shadow: 0 0 0 1px rgba(0, 0, 0, .2);
      border-radius: 4px;
      min-width: 50px;
      width: 300px;
    `;

    list.innerHTML = `<div>
      <div style="font-weight: bold;">独轮车 LAPLACE ver.</div>
      <div style="margin: .5em 0; display: flex; align-items: center; flex-wrap: wrap; gap: .25em;">
        <button id="sendBtn">开启独轮车</button>
        <select id="templateSelect"></select>
        <button id="addTemplateBtn">新增</button>
        <button id="removeTemplateBtn">删除当前</button>
      </div>
      <textarea id="msgList" placeholder="在这输入弹幕,每行一句话,超过可发送字数的会自动进行分割" style="height: 100px; width: 100%; resize: none;"></textarea>
      <div style="margin: .5em 0;">
        <span id="msgCount"></span><span>间隔</span>
        <input id="msgSendInterval" style="width: 30px;" autocomplete="off" type="number" min="0" value="${GM_getValue('msgSendInterval')}" />
        <span>秒,</span>
        <span>超过</span>
        <input id="maxLength" style="width: 30px;" autocomplete="off" type="number" min="1" value="${GM_getValue('maxLength')}" />
        <span>字自动分段,</span>
        <span style="display: inline-flex; align-items: center; gap: .25em;">
          <input id="randomColor" type="checkbox" ${GM_getValue('randomColor') ? 'checked' : ''} />
          <label for="randomColor">随机颜色</label>
        </span>
        <span style="display: inline-flex; align-items: center; gap: .25em;">
          <input id="randomInterval" type="checkbox" ${GM_getValue('randomInterval') ? 'checked' : ''} />
          <label for="randomInterval">间隔增加随机性</label>
        </span>
      </div>
      <textarea id="msgLogs" style="height: 80px; width: 100%; resize: none;" placeholder="此处将输出日志(最多保留 ${GM_getValue('maxLogLines')} 条)" readonly></textarea>
      </div>`;

    document.body.appendChild(list);

    const sendBtn = document.getElementById('sendBtn');
    const msgLogs = document.getElementById('msgLogs');
    const maxLogLines = GM_getValue('maxLogLines');

    sendBtn.addEventListener('click', () => {
      if (!sendMsg) {
        const currentTemplate = MsgTemplates[activeTemplateIndex] || '';
        if (!currentTemplate.trim()) {
          appendToLimitedLog(msgLogs, '⚠️ 当前模板为空,请先输入内容', maxLogLines);
          return;
        }

        updateMessages();
        sendMsg = true;
        sendBtn.textContent = '关闭独轮车';
        toggleBtn.style.background = 'rgb(0 186 143)';
      } else {
        sendMsg = false;
        sendBtn.textContent = '开启独轮车';
        toggleBtn.style.background = 'rgb(166 166 166)';
      }
    });

    toggleBtn.addEventListener('click', () => {
      list.style.display = list.style.display === 'none' ? 'block' : 'none';
    });

    const msgInput = document.getElementById('msgList');
    const msgCount = document.getElementById('msgCount');
    const msgIntervalInput = document.getElementById('msgSendInterval');
    const maxLengthInput = document.getElementById('maxLength');
    const randomColorInput = document.getElementById('randomColor');
    const randomIntervalInput = document.getElementById('randomInterval');
    const templateSelect = document.getElementById('templateSelect');
    const addTemplateBtn = document.getElementById('addTemplateBtn');
    const removeTemplateBtn = document.getElementById('removeTemplateBtn');

    function updateMessages() {
      const maxLength = parseInt(maxLengthInput.value) || 20;
      MsgTemplates[activeTemplateIndex] = msgInput.value;
      GM_setValue('MsgTemplates', MsgTemplates);
      const Msg = processMessages(msgInput.value, maxLength);
      msgCount.textContent = `${Msg.length || 0} 条,`;
    }

    function updateTemplateSelect() {
      templateSelect.innerHTML = '';
      MsgTemplates.forEach((template, index) => {
        const option = document.createElement('option');
        option.value = index;
        option.textContent = `模板 ${index + 1}`;
        templateSelect.appendChild(option);
      });
      templateSelect.value = activeTemplateIndex;
      msgInput.value = MsgTemplates[activeTemplateIndex] || '';
      updateMessages();
    }

    templateSelect.addEventListener('change', () => {
      activeTemplateIndex = parseInt(templateSelect.value);
      GM_setValue('activeTemplateIndex', activeTemplateIndex);
      msgInput.value = MsgTemplates[activeTemplateIndex] || '';
      updateMessages();
    });

    addTemplateBtn.addEventListener('click', () => {
      MsgTemplates.push('');
      activeTemplateIndex = MsgTemplates.length - 1;
      GM_setValue('MsgTemplates', MsgTemplates);
      GM_setValue('activeTemplateIndex', activeTemplateIndex);
      updateTemplateSelect();
    });

    removeTemplateBtn.addEventListener('click', () => {
      if (MsgTemplates.length > 1) {
        MsgTemplates.splice(activeTemplateIndex, 1);
        activeTemplateIndex = Math.max(0, activeTemplateIndex - 1);
        GM_setValue('MsgTemplates', MsgTemplates);
        GM_setValue('activeTemplateIndex', activeTemplateIndex);
        updateTemplateSelect();
      }
    });

    msgInput.addEventListener('input', () => {
      updateMessages();
    });

    msgIntervalInput.addEventListener('input', () => {
      if (!(parseInt(msgIntervalInput.value) >= 0)) msgIntervalInput.value = 0;
      GM_setValue('msgSendInterval', msgIntervalInput.value);
    });

    randomColorInput.addEventListener('input', () => {
      GM_setValue('randomColor', randomColorInput.checked);
    });

    randomIntervalInput.addEventListener('input', () => {
      GM_setValue('randomInterval', randomIntervalInput.checked);
    });

    maxLengthInput.addEventListener('input', () => {
      const value = parseInt(maxLengthInput.value);
      if (value < 1) maxLengthInput.value = 1;
      GM_setValue('maxLength', maxLengthInput.value);
      updateMessages();
    });

    updateTemplateSelect();

    loop();
    clearInterval(check);
  }, 100);
})();

async function loop() {
  let count = 0;
  const msgLogs = document.getElementById('msgLogs');
  const maxLogLines = GM_getValue('maxLogLines');
  const shortUid = extractRoomNumber(window.location.href);

  const room = await fetch(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${shortUid}`, {
    method: 'GET',
    credentials: 'include'
  });

  const roomData = await room.json();
  const roomId = roomData.data.room_id;
  const csrfToken = document.cookie
  .split(';')
  .map(c => c.trim())
  .find(c => c.startsWith('bili_jct='))
  ?.split('bili_jct=')[1];

  while (true) {
    if (sendMsg) {
      const currentTemplate = MsgTemplates[activeTemplateIndex] || '';
      if (!currentTemplate.trim()) {
        appendToLimitedLog(msgLogs, '⚠️ 当前模板为空,已自动停止运行', maxLogLines);
        sendMsg = false;
        const sendBtn = document.getElementById('sendBtn');
        const toggleBtn = document.getElementById('toggleBtn');
        sendBtn.textContent = '开启独轮车';
        toggleBtn.style.background = 'rgb(166 166 166)';
        continue;
      }

      const msgSendInterval = GM_getValue('msgSendInterval');
      const enableRandomColor = GM_getValue('randomColor');
      const enableRandomInterval = GM_getValue('randomInterval');
      const Msg = processMessages(currentTemplate, GM_getValue('maxLength'));

      for (const message of Msg) {
        if (sendMsg) {
          try {
            if (enableRandomColor) {
              const colorSet = ['0xe33fff', '0x54eed8', '0x58c1de', '0x455ff6', '0x975ef9', '0xc35986', '0xff8c21', '0x00fffc', '0x7eff00', '0xffed4f', '0xff9800']
              const randomColor = colorSet[Math.floor(Math.random() * colorSet.length)];

              const configForm = new FormData();
              configForm.append('room_id', String(roomId));
              configForm.append('color', randomColor);
              configForm.append('csrf_token', csrfToken);
              configForm.append('csrf', csrfToken);
              configForm.append('visit_id', '');

              const updateConfig = await fetch('https://api.live.bilibili.com/xlive/web-room/v1/dM/AjaxSetConfig', {
                method: 'POST',
                credentials: 'include',
                body: configForm
              });
            }

            const form = new FormData();
            form.append('bubble', '2');
            form.append('msg', message);
            form.append('color', '16777215');
            form.append('mode', '1');
            form.append('room_type', '0');
            form.append('jumpfrom', '0');
            form.append('reply_mid', '0');
            form.append('reply_attr', '0');
            form.append('replay_dmid', '');
            form.append('statistics', '{"appId":100,"platform":5}');
            form.append('fontsize', '25');
            form.append('rnd', String(Math.floor(Date.now() / 1000)));
            form.append('roomid', String(roomId));
            form.append('csrf', csrfToken);
            form.append('csrf_token', csrfToken);

            const send = await fetch('https://api.live.bilibili.com/msg/send', {
              method: 'POST',
              credentials: 'include',
              body: form
            });

            const sendApiRes = await send.json();
            const logMessage = sendApiRes.message
            ? `❌「${message}」,原因:${sendApiRes.message}。`
              : `✅「${message}」`;

            appendToLimitedLog(msgLogs, logMessage, maxLogLines);

            const resolvedRandomInterval = enableRandomInterval ? Math.floor(Math.random() * 500) : 0
            await new Promise(r => setTimeout(r, msgSendInterval * 1000 - resolvedRandomInterval));
          } catch (error) {
            appendToLimitedLog(msgLogs, `🔴「${message}」失败,错误:${error.message}`, maxLogLines);
          }
        }
      }

      count += 1;
      appendToLimitedLog(msgLogs, `🔵第 ${count} 轮发送完成`, maxLogLines);
    } else {
      count = 0;
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}