Greasy Fork

Greasy Fork is available in English.

LAPLACE 弹幕助手 - 哔哩哔哩直播间独轮车、弹幕发送

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

// ==UserScript==
// @name         LAPLACE 弹幕助手 - 哔哩哔哩直播间独轮车、弹幕发送
// @namespace    http://greasyfork.icu/users/1524935
// @version      2.2.0
// @description  这是 bilibili 直播间简易版独轮车,基于 quiet/thusiant cmd 版本 http://greasyfork.icu/scripts/421507 继续维护而来
// @author       laplace-live
// @license      AGPL-3.0
// @icon         https://laplace.live/favicon.ico
// @match        *://live.bilibili.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @run-at       document-start
// ==/UserScript==

/**
 * API configs
 * @const {Object.<string, string>}
 */
const BASE_URL = {
  /**
   * Fetches room basic info
   * @method GET
   * @param {string} id - room ID
   */
  BILIBILI_ROOM_INIT: 'https://api.live.bilibili.com/room/v1/Room/room_init',

  /**
   * Send chat
   * @method POST
   * @param {string} web_location - SPM prefix
   * @param {string} w_rid - WBI signature
   * @param {string} wts - WBI timestamp
   */
  BILIBILI_MSG_SEND: 'https://api.live.bilibili.com/msg/send',

  /**
   * Chat config
   * @method POST
   */
  BILIBILI_MSG_CONFIG: 'https://api.live.bilibili.com/xlive/web-room/v1/dM/AjaxSetConfig',

  /**
   * Get danmaku config by group
   * @method GET
   * @param {string} room_id - room ID
   * @param {string} web_location - SPM prefix
   * @param {string} w_rid - WBI signature
   * @param {string} wts - WBI timestamp
   */
  BILIBILI_GET_DM_CONFIG: 'https://api.live.bilibili.com/xlive/web-room/v1/dM/GetDMConfigByGroup',

  LAPLACE_CHAT_AUDIT: 'https://edge-workers.laplace.cn/laplace/chat-audit',
  // REMOTE_KEYWORDS: 'https://raw.githubusercontent.com/laplace-live/public/refs/heads/master/artifacts/livesrtream-keywords.json',
  REMOTE_KEYWORDS: 'https://workers.vrp.moe/gh-raw/laplace-live/public/master/artifacts/livesrtream-keywords.json',
}

/**
 * @typedef {Object} DanmakuColor
 * @property {string} name - Color name
 * @property {string} color - Color value in decimal
 * @property {string} color_hex - Color value in hex
 * @property {number} status - Status (0: disabled, 1: enabled)
 * @property {number} weight - Weight for sorting
 * @property {number} color_id - Color ID
 * @property {number} origin - Origin group
 */

/**
 * @typedef {Object} DanmakuColorGroup
 * @property {string} name - Group name
 * @property {number} sort - Sort order
 * @property {DanmakuColor[]} color - Available colors in this group
 */

/**
 * @typedef {Object} DanmakuMode
 * @property {string} name - Mode name
 * @property {number} mode - Mode value (1: scroll, 4: bottom, 5: top)
 * @property {string} type - Mode type string
 * @property {number} status - Status (0: disabled, 1: enabled)
 */

/**
 * @typedef {Object} DanmakuConfigData
 * @property {DanmakuColorGroup[]} group - Color groups
 * @property {DanmakuMode[]} mode - Display modes
 */

/**
 * @typedef {Object} DanmakuConfigResponse
 * @property {number} code - Response code
 * @property {DanmakuConfigData} data - Config data
 * @property {string} message - Response message
 * @property {string} msg - Response msg
 */

/**
 * Gets the spm_prefix value from the meta tag for web_location
 * @returns {string} The spm_prefix value
 */
function getSpmPrefix() {
  const metaTag = document.querySelector('meta[name="spm_prefix"]')
  return metaTag?.getAttribute('content') || '444.8'
}

// Hijack XHR to get wbi_img, which takes Claude 2 mins to bypass LOL😁
/** @type {{img_key: string, sub_key: string}|null} */
let cachedWbiKeys = null

;(() => {
  const originalOpen = XMLHttpRequest.prototype.open
  const originalSend = XMLHttpRequest.prototype.send

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._url = url
    return originalOpen.apply(this, [method, url, ...rest])
  }

  XMLHttpRequest.prototype.send = function (...args) {
    if (this._url?.includes('/x/web-interface/nav')) {
      console.log('[LAPLACE Chatterbox Helper] Intercepted request:', this._url)

      this.addEventListener('load', function () {
        try {
          const data = JSON.parse(this.responseText)
          if (data?.data?.wbi_img) {
            console.log('[LAPLACE Chatterbox Helper] wbi_img:', data.data.wbi_img)

            // Extract keys from URLs
            const img_url = data.data.wbi_img.img_url
            const sub_url = data.data.wbi_img.sub_url

            // Extract filename without extension (the key is in the filename)
            const img_key = img_url.split('/').pop().split('.')[0]
            const sub_key = sub_url.split('/').pop().split('.')[0]

            cachedWbiKeys = { img_key, sub_key }
            console.log('[LAPLACE Chatterbox Helper] Extracted WBI keys:', cachedWbiKeys)
          } else {
            console.log('[LAPLACE Chatterbox Helper] Response received but wbi_img not found:', data)
          }
        } catch (err) {
          console.error('[LAPLACE Chatterbox Helper] Error parsing response:', err)
        }
      })
    }

    return originalSend.apply(this, args)
  }
})()

/**
 * Waits for WBI keys to become available via XHR interception
 * @param {number} timeout - Maximum time to wait in ms
 * @param {number} interval - Polling interval in ms
 * @returns {Promise<boolean>} True if keys are available, false if timeout
 */
async function waitForWbiKeys(timeout = 5000, interval = 100) {
  const startTime = Date.now()
  while (!cachedWbiKeys) {
    if (Date.now() - startTime > timeout) {
      return false
    }
    await new Promise(r => setTimeout(r, interval))
  }
  return true
}

/**
 * @typedef {Object} BilibiliWbiKeys
 * @property {string} img_key - Image key extracted from wbi_img
 * @property {string} sub_key - Sub key extracted from wbi_img
 */

/** @type {string[]|null} */
let availableDanmakuColors = null

// https://s1.hdslb.com/bfs/static/laputa-home/client/assets/vendor.7679ec63.js
// function getMixinKey(ae){var oe=[46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52]
const mixinKeyEncTab = [
  46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41,
  13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34,
  44, 52,
]

/**
 * Computes MD5 hash of a string in 2025😁
 * @param {string} str - The string to hash
 * @returns {string} The MD5 hash in hexadecimal format
 */
function md5(str) {
  function rotateLeft(n, s) {
    return (n << s) | (n >>> (32 - s))
  }

  function addUnsigned(x, y) {
    const lsw = (x & 0xffff) + (y & 0xffff)
    const msw = (x >> 16) + (y >> 16) + (lsw >> 16)
    return (msw << 16) | (lsw & 0xffff)
  }

  function cmn(q, a, b, x, s, t) {
    return addUnsigned(rotateLeft(addUnsigned(addUnsigned(a, q), addUnsigned(x, t)), s), b)
  }

  function ff(a, b, c, d, x, s, t) {
    return cmn((b & c) | (~b & d), a, b, x, s, t)
  }

  function gg(a, b, c, d, x, s, t) {
    return cmn((b & d) | (c & ~d), a, b, x, s, t)
  }

  function hh(a, b, c, d, x, s, t) {
    return cmn(b ^ c ^ d, a, b, x, s, t)
  }

  function ii(a, b, c, d, x, s, t) {
    return cmn(c ^ (b | ~d), a, b, x, s, t)
  }

  function convertToWordArray(str) {
    const wordArray = []
    for (let i = 0; i < str.length * 8; i += 8) {
      wordArray[i >> 5] |= (str.charCodeAt(i / 8) & 0xff) << (i % 32)
    }
    return wordArray
  }

  function wordToHex(value) {
    let hex = ''
    for (let i = 0; i < 4; i++) {
      hex += ((value >> (i * 8 + 4)) & 0x0f).toString(16) + ((value >> (i * 8)) & 0x0f).toString(16)
    }
    return hex
  }

  const x = convertToWordArray(str)
  let a = 0x67452301
  let b = 0xefcdab89
  let c = 0x98badcfe
  let d = 0x10325476

  x[str.length >> 2] |= 0x80 << ((str.length % 4) * 8)
  x[(((str.length + 8) >> 6) << 4) + 14] = str.length * 8

  for (let i = 0; i < x.length; i += 16) {
    const oldA = a
    const oldB = b
    const oldC = c
    const oldD = d

    a = ff(a, b, c, d, x[i + 0], 7, 0xd76aa478)
    d = ff(d, a, b, c, x[i + 1], 12, 0xe8c7b756)
    c = ff(c, d, a, b, x[i + 2], 17, 0x242070db)
    b = ff(b, c, d, a, x[i + 3], 22, 0xc1bdceee)
    a = ff(a, b, c, d, x[i + 4], 7, 0xf57c0faf)
    d = ff(d, a, b, c, x[i + 5], 12, 0x4787c62a)
    c = ff(c, d, a, b, x[i + 6], 17, 0xa8304613)
    b = ff(b, c, d, a, x[i + 7], 22, 0xfd469501)
    a = ff(a, b, c, d, x[i + 8], 7, 0x698098d8)
    d = ff(d, a, b, c, x[i + 9], 12, 0x8b44f7af)
    c = ff(c, d, a, b, x[i + 10], 17, 0xffff5bb1)
    b = ff(b, c, d, a, x[i + 11], 22, 0x895cd7be)
    a = ff(a, b, c, d, x[i + 12], 7, 0x6b901122)
    d = ff(d, a, b, c, x[i + 13], 12, 0xfd987193)
    c = ff(c, d, a, b, x[i + 14], 17, 0xa679438e)
    b = ff(b, c, d, a, x[i + 15], 22, 0x49b40821)

    a = gg(a, b, c, d, x[i + 1], 5, 0xf61e2562)
    d = gg(d, a, b, c, x[i + 6], 9, 0xc040b340)
    c = gg(c, d, a, b, x[i + 11], 14, 0x265e5a51)
    b = gg(b, c, d, a, x[i + 0], 20, 0xe9b6c7aa)
    a = gg(a, b, c, d, x[i + 5], 5, 0xd62f105d)
    d = gg(d, a, b, c, x[i + 10], 9, 0x02441453)
    c = gg(c, d, a, b, x[i + 15], 14, 0xd8a1e681)
    b = gg(b, c, d, a, x[i + 4], 20, 0xe7d3fbc8)
    a = gg(a, b, c, d, x[i + 9], 5, 0x21e1cde6)
    d = gg(d, a, b, c, x[i + 14], 9, 0xc33707d6)
    c = gg(c, d, a, b, x[i + 3], 14, 0xf4d50d87)
    b = gg(b, c, d, a, x[i + 8], 20, 0x455a14ed)
    a = gg(a, b, c, d, x[i + 13], 5, 0xa9e3e905)
    d = gg(d, a, b, c, x[i + 2], 9, 0xfcefa3f8)
    c = gg(c, d, a, b, x[i + 7], 14, 0x676f02d9)
    b = gg(b, c, d, a, x[i + 12], 20, 0x8d2a4c8a)

    a = hh(a, b, c, d, x[i + 5], 4, 0xfffa3942)
    d = hh(d, a, b, c, x[i + 8], 11, 0x8771f681)
    c = hh(c, d, a, b, x[i + 11], 16, 0x6d9d6122)
    b = hh(b, c, d, a, x[i + 14], 23, 0xfde5380c)
    a = hh(a, b, c, d, x[i + 1], 4, 0xa4beea44)
    d = hh(d, a, b, c, x[i + 4], 11, 0x4bdecfa9)
    c = hh(c, d, a, b, x[i + 7], 16, 0xf6bb4b60)
    b = hh(b, c, d, a, x[i + 10], 23, 0xbebfbc70)
    a = hh(a, b, c, d, x[i + 13], 4, 0x289b7ec6)
    d = hh(d, a, b, c, x[i + 0], 11, 0xeaa127fa)
    c = hh(c, d, a, b, x[i + 3], 16, 0xd4ef3085)
    b = hh(b, c, d, a, x[i + 6], 23, 0x04881d05)
    a = hh(a, b, c, d, x[i + 9], 4, 0xd9d4d039)
    d = hh(d, a, b, c, x[i + 12], 11, 0xe6db99e5)
    c = hh(c, d, a, b, x[i + 15], 16, 0x1fa27cf8)
    b = hh(b, c, d, a, x[i + 2], 23, 0xc4ac5665)

    a = ii(a, b, c, d, x[i + 0], 6, 0xf4292244)
    d = ii(d, a, b, c, x[i + 7], 10, 0x432aff97)
    c = ii(c, d, a, b, x[i + 14], 15, 0xab9423a7)
    b = ii(b, c, d, a, x[i + 5], 21, 0xfc93a039)
    a = ii(a, b, c, d, x[i + 12], 6, 0x655b59c3)
    d = ii(d, a, b, c, x[i + 3], 10, 0x8f0ccc92)
    c = ii(c, d, a, b, x[i + 10], 15, 0xffeff47d)
    b = ii(b, c, d, a, x[i + 1], 21, 0x85845dd1)
    a = ii(a, b, c, d, x[i + 8], 6, 0x6fa87e4f)
    d = ii(d, a, b, c, x[i + 15], 10, 0xfe2ce6e0)
    c = ii(c, d, a, b, x[i + 6], 15, 0xa3014314)
    b = ii(b, c, d, a, x[i + 13], 21, 0x4e0811a1)
    a = ii(a, b, c, d, x[i + 4], 6, 0xf7537e82)
    d = ii(d, a, b, c, x[i + 11], 10, 0xbd3af235)
    c = ii(c, d, a, b, x[i + 2], 15, 0x2ad7d2bb)
    b = ii(b, c, d, a, x[i + 9], 21, 0xeb86d391)

    a = addUnsigned(a, oldA)
    b = addUnsigned(b, oldB)
    c = addUnsigned(c, oldC)
    d = addUnsigned(d, oldD)
  }

  return wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)
}

/**
 * Applies character order scrambling encoding to imgKey and subKey
 * @param {string} orig - Original string to encode (imgKey + subKey concatenated)
 * @returns {string} Mixed key (first 32 characters)
 */
function getMixinKey(orig) {
  return mixinKeyEncTab
    .map(n => orig[n])
    .join('')
    .slice(0, 32)
}

/**
 * Adds wts field to request parameters and performs wbi signature
 * @param {Object.<string, string|number>} params - Request parameters
 * @param {BilibiliWbiKeys} wbiKeys - WBI keys object
 * @returns {string} Query string with w_rid and wts parameters
 */
function encodeWbi(params, wbiKeys) {
  const mixin_key = getMixinKey(wbiKeys.img_key + wbiKeys.sub_key)
  const currentTime = Math.round(Date.now() / 1000)
  const charaFilter = /[!'()*]/g

  // Add wts field
  /** @type {Object.<string, string|number>} */
  const paramsWithWts = { ...params, wts: currentTime }

  // Sort parameters by key (only for signature calculation)
  const sortedQuery = Object.keys(paramsWithWts)
    .sort()
    .map(key => {
      // Filter "!'()*" characters from value
      const resolvedValue = paramsWithWts[key]?.toString() || ''
      const value = resolvedValue.replace(charaFilter, '')
      return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
    })
    .join('&')

  // Calculate w_rid
  const wbi_sign = md5(sortedQuery + mixin_key)

  // Build returned query string (maintain original order, wts at the end)
  const unsortedQuery = Object.keys(params)
    .map(key => {
      const resolvedValue = params[key]?.toString() || ''
      const value = resolvedValue.replace(charaFilter, '')
      return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
    })
    .join('&')

  return `${unsortedQuery}&w_rid=${wbi_sign}&wts=${currentTime}`
}

/** @type {string[]} */
const MsgTemplates = GM_getValue('MsgTemplates', [])

/** @type {number} */
let activeTemplateIndex = GM_getValue('activeTemplateIndex', 0)

/** @type {Object.<string, number|boolean>} */
const scriptInitVal = {
  msgSendInterval: 1,
  maxLength: 20,
  maxLogLines: 1000,
  randomColor: false,
  randomInterval: false,
  randomChar: false,
  aiEvasion: false,
  forceScrollDanmaku: false,
}

for (const initVal in scriptInitVal) {
  if (GM_getValue(initVal) === undefined) GM_setValue(initVal, scriptInitVal[initVal])
}

/** @type {boolean} */
let sendMsg = false

/**
 * Splits a string into grapheme clusters (user-perceived characters)
 * @param {string} str - The string to split into graphemes
 * @returns {string[]} An array of grapheme clusters
 */
function getGraphemes(str) {
  const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' })
  return Array.from(segmenter.segment(str), ({ segment }) => segment)
}

/**
 * Emoji-safe splitting text into parts based on maximum grapheme length
 * @param {string} text - The text to split
 * @param {number} maxLength - Maximum number of graphemes per part
 * @returns {string[]} An array of text parts, each within the maxLength
 */
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
}

/**
 * Appends a message to a textarea log with a maximum line limit
 * @param {HTMLTextAreaElement} logElement - The textarea element to append to
 * @param {string} message - The message to append
 * @param {number} maxLines - Maximum number of lines to keep in the log
 * @returns {void}
 */
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
}

/**
 * Extracts the room number from a Bilibili live room URL
 * @param {string} url - The URL to extract the room number from
 * @returns {string|undefined} The room number, or undefined if not found
 */
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
}

/**
 * Adds a random soft hyphen character at a random position in the text
 * @param {string} text - The text to modify
 * @returns {string} The modified text with a random character inserted
 */
function addRandomCharacter(text) {
  if (!text || text.length === 0) return text

  const graphemes = getGraphemes(text)
  const randomIndex = Math.floor(Math.random() * (graphemes.length + 1))
  graphemes.splice(randomIndex, 0, '­')
  return graphemes.join('')
}

/**
 * Processes messages by splitting lines, optionally adding random characters, and trimming to max length
 * @param {string} text - The text containing messages (one per line)
 * @param {number} maxLength - Maximum grapheme length per message
 * @param {boolean} [addRandomChar=false] - Whether to add random characters to each line
 * @returns {string[]} An array of processed message strings
 */
function processMessages(text, maxLength, addRandomChar = false) {
  return text
    .split('\n')
    .flatMap(line => {
      // Add random character if enabled
      if (addRandomChar && line && line.trim()) {
        line = addRandomCharacter(line)
      }
      // Then trim based on maxLength
      return trimText(line, maxLength)
    })
    .filter(line => line?.trim())
}

/** @type {number|null} */
let cachedRoomId = null

/** @type {Function|null} */
let onRoomIdReadyCallback = null

/** @type {Map<string, string>|null} */
let replacementMap = null

;(() => {
  const check = setInterval(() => {
    /** @type {HTMLDivElement} */
    const toggleBtn = document.createElement('div')
    toggleBtn.id = 'toggleBtn'
    toggleBtn.textContent = '弹幕助手'
    toggleBtn.style.cssText = `
      position: fixed;
      right: 4px;
      bottom: 4px;
      z-index: 2147483647;
      cursor: pointer;
      background: #777;
      color: white;
      padding: 6px 8px;
      border-radius: 4px;
      user-select: none;
    `
    document.body.appendChild(toggleBtn)

    /** @type {HTMLDivElement} */
    const list = document.createElement('div')
    list.style.cssText = `
      position: fixed;
      right: 4px;
      bottom: calc(4px + 30px);
      z-index: 2147483647;
      background: var(--bg1, #fff);
      display: none;
      padding: 10px;
      box-shadow: 0 0 0 1px var(--Ga2, rgba(0, 0, 0, .2));
      border-radius: 4px;
      min-width: 50px;
      max-height: calc(100vh - 64px);
      overflow-y: auto;
      width: 300px;
    `

    list.innerHTML = `<div>
      <!-- Tab Navigation -->
      <div style="display: flex; margin-block: -5px .75em; margin-inline: -10px; padding: 0 10px; gap: .25em; border-bottom: 1px solid var(--Ga2, #ddd);">
        <button id="tab-dulunche" class="tab-btn" style="padding: .25em .75em; margin-bottom: -1px; border: none; background: none; cursor: pointer; border-bottom: 1px solid transparent;">独轮车</button>
        <button id="tab-fasong" class="tab-btn" style="padding: .25em .75em; margin-bottom: -1px; border: none; background: none; cursor: pointer; border-bottom: 1px solid transparent;">常规发送</button>
        <button id="tab-settings" class="tab-btn" style="padding: .25em .75em; margin-bottom: -1px; border: none; background: none; cursor: pointer; border-bottom: 1px solid transparent;">设置</button>
      </div>

      <!-- Tab Content: 独轮车 -->
      <div id="content-dulunche" class="tab-content" style="display: none;">
        <div style="margin: .5em 0; display: flex; align-items: center; flex-wrap: wrap; gap: .25em;">
          <button id="sendBtn">开启独轮车</button>
          <select id="templateSelect" style="width: 16ch;"></select>
          <button id="addTemplateBtn">新增</button>
          <button id="removeTemplateBtn">删除当前</button>
        </div>
        <textarea id="msgList" placeholder="在这输入弹幕,每行一句话,超过可发送字数的会自动进行分割" style="box-sizing: border-box; height: 100px; width: 100%; resize: vertical;"></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>
          <span style="display: inline-flex; align-items: center; gap: .25em;">
            <input id="randomChar" type="checkbox" ${GM_getValue('randomChar') ? 'checked' : ''} />
            <label for="randomChar">随机字符</label>
          </span>
        </div>
      </div>

      <!-- Tab Content: 发送 -->
      <div id="content-fasong" class="tab-content" style="display: none;">
        <div style="margin: .5em 0;">
          <textarea id="fasongInput" placeholder="输入弹幕内容… (Enter 发送)" style="box-sizing: border-box; height: 50px; width: 100%; resize: vertical;"></textarea>
        </div>
        <div style="margin: .5em 0;">
          <span style="display: inline-flex; align-items: center; gap: .25em;">
            <input id="aiEvasion" type="checkbox" ${GM_getValue('aiEvasion') ? 'checked' : ''} />
            <label for="aiEvasion">AI规避(发送失败时自动检测敏感词并重试)</label>
          </span>
        </div>
      </div>

      <!-- Tab Content: 全局设置 -->
      <div id="content-settings" class="tab-content" style="display: none;">
        <!-- Remote Keyword Sync -->
        <div style="margin: .5em 0; padding-bottom: .5em; border-bottom: 1px solid var(--Ga2, #eee);">
          <div style="font-weight: bold; margin-bottom: .5em;">
            云端规则替换
            <a href="https://github.com/laplace-live/public/blob/master/artifacts/livesrtream-keywords.json" target="_blank" style="color: #288bb8; text-decoration: none;">我要贡献规则</a>
          </div>
          <div style="margin-block: .5em; color: #666;">
            每10分钟会自动同步云端替换规则
          </div>
          <div style="display: flex; gap: .5em; align-items: center; flex-wrap: wrap; margin-bottom: .5em;">
            <button id="syncRemoteBtn">同步</button>
            <button id="testRemoteBtn">测试云端词库</button>
            <span id="remoteKeywordsStatus" style="color: #666;">未同步</span>
          </div>
          <div id="remoteKeywordsInfo" style="color: #666;"></div>
        </div>

        <!-- Local Replacement Rules -->
        <div style="margin: .5em 0; padding-bottom: .5em; border-bottom: 1px solid var(--Ga2, #eee);">
          <div style="display: flex; gap: .5em; align-items: center; flex-wrap: wrap; margin-bottom: .5em;">
            <div style="font-weight: bold;">本地规则替换</div>
            <button id="testLocalBtn">测试本地词库</button>
          </div>
          <div style="margin-block: .5em; color: #666;">规则从上至下执行;本地规则总是最后执行</div>
          <div id="replacementRulesList" style="margin-bottom: .5em; max-height: 160px; overflow-y: auto;"></div>
          <div style="display: flex; gap: .25em; align-items: center; flex-wrap: wrap;">
            <input id="replaceFrom" placeholder="替换前" style="flex: 1; min-width: 80px;" />
            <span>→</span>
            <input id="replaceTo" placeholder="替换后" style="flex: 1; min-width: 80px;" />
            <button id="addRuleBtn">添加</button>
          </div>
        </div>

        <!-- Log Settings -->
        <div style="margin: .5em 0; padding-bottom: .5em; border-bottom: 1px solid var(--Ga2, #eee);">
          <div style="font-weight: bold; margin-bottom: .5em;">日志设置</div>
          <div style="display: flex; gap: .5em; align-items: center; flex-wrap: wrap;">
            <label for="maxLogLinesInput" style="color: #666;">最大日志行数:</label>
            <input id="maxLogLinesInput" type="number" min="1" max="1000" value="${GM_getValue('maxLogLines')}" style="width: 80px;" />
            <span style="color: #999; font-size: 0.9em;">(1-1000)</span>
          </div>
        </div>

        <!-- Other Settings -->
        <div style="margin: .5em 0;">
          <div style="font-weight: bold; margin-bottom: .5em;">其他设置</div>
          <div style="display: flex; gap: .5em; align-items: center; flex-wrap: wrap;">
            <span style="display: inline-flex; align-items: center; gap: .25em;">
              <input id="forceScrollDanmaku" type="checkbox" ${GM_getValue('forceScrollDanmaku') ? 'checked' : ''} />
              <label for="forceScrollDanmaku">脚本载入时强制配置弹幕位置为滚动方向</label>
            </span>
          </div>
        </div>
      </div>

      <!-- Global Log Area -->
      <details style="margin-top: .25em;">
        <summary style="cursor: pointer; user-select: none; font-weight: bold;">日志</summary>
        <textarea id="msgLogs" style="box-sizing: border-box; height: 80px; width: 100%; resize: vertical; margin-top: .5em;" placeholder="此处将输出日志(最多保留 ${GM_getValue('maxLogLines')} 条)" readonly></textarea>
      </details>
      </div>`

    document.body.appendChild(list)

    // Tab switching logic
    /** @type {string} */
    const activeTab = GM_getValue('activeTab', 'dulunche')

    /**
     * Switches to the specified tab and saves the state
     * @param {string} tabId - The tab identifier (dulunche or fasong)
     * @returns {void}
     */
    function switchTab(tabId) {
      // Hide all tab contents
      document.querySelectorAll('.tab-content').forEach(content => {
        content.style.display = 'none'
      })

      // Remove active state from all tabs
      document.querySelectorAll('.tab-btn').forEach(btn => {
        btn.style.borderBottom = '1px solid transparent'
        btn.style.fontWeight = 'normal'
      })

      // Show selected tab content
      const contentElement = document.getElementById(`content-${tabId}`)
      if (contentElement) {
        contentElement.style.display = 'block'
      }

      // Highlight active tab button
      const tabBtn = document.getElementById(`tab-${tabId}`)
      if (tabBtn) {
        tabBtn.style.borderBottom = '1px solid #36a185'
        tabBtn.style.fontWeight = 'bold'
      }

      // Save active tab
      GM_setValue('activeTab', tabId)
    }

    // Setup tab click handlers
    document.getElementById('tab-dulunche')?.addEventListener('click', () => {
      switchTab('dulunche')
    })

    document.getElementById('tab-fasong')?.addEventListener('click', () => {
      switchTab('fasong')
    })

    document.getElementById('tab-settings')?.addEventListener('click', () => {
      switchTab('settings')
    })

    // Restore last active tab
    switchTab(activeTab)

    /** @type {HTMLButtonElement} */
    const sendBtn = document.getElementById('sendBtn')
    /** @type {HTMLTextAreaElement} */
    const msgLogs = document.getElementById('msgLogs')
    /** @type {number} */
    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'
    })

    /** @type {HTMLTextAreaElement} */
    const msgInput = document.getElementById('msgList')
    /** @type {HTMLSpanElement} */
    const msgCount = document.getElementById('msgCount')
    /** @type {HTMLInputElement} */
    const msgIntervalInput = document.getElementById('msgSendInterval')
    /** @type {HTMLInputElement} */
    const maxLengthInput = document.getElementById('maxLength')
    /** @type {HTMLInputElement} */
    const randomColorInput = document.getElementById('randomColor')
    /** @type {HTMLInputElement} */
    const randomIntervalInput = document.getElementById('randomInterval')
    /** @type {HTMLInputElement} */
    const randomCharInput = document.getElementById('randomChar')
    /** @type {HTMLSelectElement} */
    const templateSelect = document.getElementById('templateSelect')
    /** @type {HTMLButtonElement} */
    const addTemplateBtn = document.getElementById('addTemplateBtn')
    /** @type {HTMLButtonElement} */
    const removeTemplateBtn = document.getElementById('removeTemplateBtn')

    /**
     * Updates the current template with input content and refreshes message count
     * @returns {void}
     */
    function updateMessages() {
      const maxLength = parseInt(maxLengthInput.value, 10) || 20
      MsgTemplates[activeTemplateIndex] = msgInput.value
      GM_setValue('MsgTemplates', MsgTemplates)
      const Msg = processMessages(msgInput.value, maxLength)
      msgCount.textContent = `${Msg.length || 0} 条,`
    }

    /**
     * Updates the template select dropdown with current templates
     * @returns {void}
     */
    function updateTemplateSelect() {
      templateSelect.innerHTML = ''
      MsgTemplates.forEach((template, index) => {
        const option = document.createElement('option')
        option.value = index

        // Get first line of template and truncate to 20 characters
        const firstLine = template.split('\n')[0].trim()
        const preview = firstLine
          ? getGraphemes(firstLine).length > 10
            ? `${trimText(firstLine, 10)[0]}…`
            : firstLine
          : '(空)'

        option.textContent = `${index + 1}: ${preview}`
        templateSelect.appendChild(option)
      })
      templateSelect.value = activeTemplateIndex
      msgInput.value = MsgTemplates[activeTemplateIndex] || ''
      updateMessages()
    }

    templateSelect.addEventListener('change', () => {
      activeTemplateIndex = parseInt(templateSelect.value, 10)
      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()
      updateTemplateSelect()
    })

    msgIntervalInput.addEventListener('input', () => {
      if (!(parseInt(msgIntervalInput.value, 10) >= 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)
    })

    randomCharInput.addEventListener('input', () => {
      GM_setValue('randomChar', randomCharInput.checked)
    })

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

    updateTemplateSelect()

    // ===== 发送 Tab Features =====
    /** @type {Array<{from: string, to: string}>} */
    const replacementRules = GM_getValue('replacementRules', [])

    /** @type {HTMLTextAreaElement} */
    const fasongInput = document.getElementById('fasongInput')
    /** @type {HTMLInputElement} */
    const aiEvasionInput = document.getElementById('aiEvasion')
    /** @type {HTMLDivElement} */
    const replacementRulesList = document.getElementById('replacementRulesList')
    /** @type {HTMLInputElement} */
    const replaceFromInput = document.getElementById('replaceFrom')
    /** @type {HTMLInputElement} */
    const replaceToInput = document.getElementById('replaceTo')
    /** @type {HTMLButtonElement} */
    const addRuleBtn = document.getElementById('addRuleBtn')

    /**
     * Updates the display of replacement rules
     * @returns {void}
     */
    function updateReplacementRulesDisplay() {
      if (replacementRules.length === 0) {
        replacementRulesList.innerHTML = '<div style="color: #999;">暂无替换规则,请在下方添加</div>'
        return
      }

      replacementRulesList.innerHTML = replacementRules
        .map((rule, index) => {
          const fromDisplay = rule.from || '(空)'
          const toDisplay = rule.to || '(空)'
          return `
            <div style="display: flex; align-items: center; gap: .5em; padding: .2em; border-bottom: 1px solid var(--Ga2, #eee);">
              <span style="flex: 1; word-break: break-all; font-family: monospace;">${fromDisplay} → ${toDisplay}</span>
              <button class="remove-rule-btn" data-index="${index}" style="cursor: pointer; background: transparent; color: red; border: none; border-radius: 2px;">删除</button>
            </div>
          `
        })
        .join('')

      // Add event listeners to remove buttons
      document.querySelectorAll('.remove-rule-btn').forEach(btn => {
        btn.addEventListener('click', e => {
          const index = parseInt(e.target.getAttribute('data-index'), 10)
          replacementRules.splice(index, 1)
          GM_setValue('replacementRules', replacementRules)
          buildReplacementMap() // Rebuild map when rules change
          updateReplacementRulesDisplay()
        })
      })
    }

    // Add new replacement rule
    addRuleBtn.addEventListener('click', () => {
      const from = replaceFromInput.value
      const to = replaceToInput.value

      if (!from) {
        appendToLimitedLog(msgLogs, '⚠️ 替换前的内容不能为空', maxLogLines)
        return
      }

      replacementRules.push({ from, to })
      GM_setValue('replacementRules', replacementRules)
      buildReplacementMap() // Rebuild map when rules change

      replaceFromInput.value = ''
      replaceToInput.value = ''

      updateReplacementRulesDisplay()
      // appendToLimitedLog(msgLogs, `✅ 已添加替换规则:${from} → ${to}`, maxLogLines);
    })

    // Allow Enter key to add rule in replace inputs
    replaceFromInput.addEventListener('keypress', e => {
      if (e.key === 'Enter') {
        e.preventDefault()
        addRuleBtn.click()
      }
    })

    replaceToInput.addEventListener('keypress', e => {
      if (e.key === 'Enter') {
        e.preventDefault()
        addRuleBtn.click()
      }
    })

    // AI Evasion functionality
    /**
     * Calls AI endpoint to detect sensitive words
     * @param {string} text - The text to check
     * @returns {Promise<{hasSensitiveContent: boolean, sensitiveWords?: string[], severity?: string, categories?: string[]}>}
     */
    async function detectSensitiveWords(text) {
      try {
        const resp = await fetch(BASE_URL.LAPLACE_CHAT_AUDIT, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            completionMetadata: {
              input: text,
            },
          }),
        })

        if (!resp.ok) {
          throw new Error(`HTTP ${resp.status}`)
        }

        const data = await resp.json()
        return data.completion || { hasSensitiveContent: false }
      } catch (error) {
        console.error('AI detection error:', error)
        appendToLimitedLog(msgLogs, `⚠️ AI检测服务出错:${error.message}`, maxLogLines)
        return { hasSensitiveContent: false }
      }
    }

    /**
     * Inserts invisible soft hyphen characters between each character of a word
     * @param {string} word - The word to modify
     * @returns {string} The word with invisible characters inserted
     */
    function insertInvisibleChars(word) {
      const graphemes = getGraphemes(word)
      return graphemes.join('­')
    }

    /**
     * Replaces sensitive words with versions that have invisible characters
     * @param {string} text - The original text
     * @param {string[]} sensitiveWords - Array of sensitive words to replace
     * @returns {string} Text with sensitive words replaced
     */
    function replaceSensitiveWords(text, sensitiveWords) {
      let result = text
      for (const word of sensitiveWords) {
        const modifiedWord = insertInvisibleChars(word)
        // Use global replace to handle all occurrences
        result = result.split(word).join(modifiedWord)
      }
      return result
    }

    // Send message functionality
    async function sendMessage() {
      const originalMessage = fasongInput.value.trim()

      if (!originalMessage) {
        appendToLimitedLog(msgLogs, '⚠️ 消息内容不能为空', maxLogLines)
        return
      }

      // Apply text replacements
      const processedMessage = applyReplacements(originalMessage)
      const wasReplaced = originalMessage !== processedMessage

      // Clear input immediately after getting the message
      fasongInput.value = ''

      try {
        // Use cached room ID, or fetch it if not available yet
        if (cachedRoomId === null) {
          cachedRoomId = await getRoomId()
        }
        const roomId = cachedRoomId
        const csrfToken = getCsrfToken()

        if (!csrfToken) {
          appendToLimitedLog(msgLogs, '❌ 未找到登录信息,请先登录 Bilibili', maxLogLines)
          return
        }

        const result = await sendDanmaku(processedMessage, roomId, csrfToken)

        if (result.success) {
          const displayMsg = wasReplaced ? `${originalMessage} → ${processedMessage}` : processedMessage
          appendToLimitedLog(msgLogs, `✅ 手动: ${displayMsg}`, maxLogLines)
        } else {
          let errorMsg = result.error || '未知错误'

          // Handle specific error codes
          if (result.error) {
            if (result.error.includes('f')) {
              errorMsg = 'f - 包含全局屏蔽词'
            } else if (result.error.includes('k')) {
              errorMsg = 'k - 包含房间屏蔽词'
            }
          }

          const displayMsg = wasReplaced ? `${originalMessage} → ${processedMessage}` : processedMessage
          appendToLimitedLog(msgLogs, `❌ 手动: ${displayMsg},原因:${errorMsg}`, maxLogLines)

          // Try AI evasion if enabled
          const aiEvasionEnabled = GM_getValue('aiEvasion', false)
          if (aiEvasionEnabled) {
            appendToLimitedLog(msgLogs, `🤖 AI规避已启用,正在检测敏感词…`, maxLogLines)

            const detection = await detectSensitiveWords(processedMessage)

            if (detection.hasSensitiveContent && detection.sensitiveWords && detection.sensitiveWords.length > 0) {
              appendToLimitedLog(
                msgLogs,
                `🤖 检测到敏感词:${detection.sensitiveWords.join(', ')},正在尝试规避…`,
                maxLogLines
              )

              const evadedMessage = replaceSensitiveWords(processedMessage, detection.sensitiveWords)
              const retryResult = await sendDanmaku(evadedMessage, roomId, csrfToken)

              if (retryResult.success) {
                appendToLimitedLog(msgLogs, `✅ AI规避成功: ${evadedMessage}`, maxLogLines)
              } else {
                appendToLimitedLog(msgLogs, `❌ AI规避失败: ${evadedMessage},原因:${retryResult.error}`, maxLogLines)
              }
            } else {
              appendToLimitedLog(msgLogs, `⚠️ 无法检测到敏感词,请手动检查`, maxLogLines)
            }
          }
        }
      } catch (error) {
        appendToLimitedLog(msgLogs, `🔴 发送出错:${error.message}`, maxLogLines)
      }
    }

    // AI Evasion checkbox event listener
    aiEvasionInput.addEventListener('input', () => {
      GM_setValue('aiEvasion', aiEvasionInput.checked)
    })

    // Allow Enter to send message
    fasongInput.addEventListener('keydown', e => {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault()
        sendMessage()
      }
    })

    // Initialize replacement rules display
    updateReplacementRulesDisplay()

    // ===== Remote Keywords Sync =====

    const SYNC_INTERVAL = 10 * 60 * 1000 // 10 minutes in milliseconds

    /** @type {HTMLButtonElement} */
    const syncRemoteBtn = document.getElementById('syncRemoteBtn')
    /** @type {HTMLSpanElement} */
    const remoteKeywordsStatus = document.getElementById('remoteKeywordsStatus')
    /** @type {HTMLDivElement} */
    const remoteKeywordsInfo = document.getElementById('remoteKeywordsInfo')

    /**
     * Fetches remote keywords from GitHub
     * @returns {Promise<{global: {keywords: Object}, rooms: Array}>}
     */
    async function fetchRemoteKeywords() {
      const response = await fetch(BASE_URL.REMOTE_KEYWORDS)
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      return await response.json()
    }

    /**
     * Syncs remote keywords and stores them locally
     * @returns {Promise<void>}
     */
    async function syncRemoteKeywords() {
      try {
        syncRemoteBtn.disabled = true
        syncRemoteBtn.textContent = '同步中…'
        remoteKeywordsStatus.textContent = '正在同步…'
        remoteKeywordsStatus.style.color = '#666'

        const data = await fetchRemoteKeywords()

        // Store the fetched data
        GM_setValue('remoteKeywords', data)
        GM_setValue('remoteKeywordsLastSync', Date.now())
        buildReplacementMap() // Rebuild map when remote keywords change

        // Update status
        updateRemoteKeywordsStatus()

        // appendToLimitedLog(msgLogs, '✅ 云端替换规则同步成功', maxLogLines)
      } catch (error) {
        remoteKeywordsStatus.textContent = `同步失败: ${error.message}`
        remoteKeywordsStatus.style.color = '#f44'
        appendToLimitedLog(msgLogs, `❌ 云端替换规则同步失败: ${error.message}`, maxLogLines)
      } finally {
        syncRemoteBtn.disabled = false
        syncRemoteBtn.textContent = '同步'
      }
    }

    /**
     * Updates the display of remote keywords status
     * @returns {void}
     */
    function updateRemoteKeywordsStatus() {
      const remoteKeywords = GM_getValue('remoteKeywords', null)
      const lastSync = GM_getValue('remoteKeywordsLastSync', null)

      if (!remoteKeywords || !lastSync) {
        remoteKeywordsStatus.textContent = '未同步'
        remoteKeywordsStatus.style.color = '#666'
        remoteKeywordsInfo.textContent = ''
        return
      }

      // Get current room ID
      const currentRoomId = cachedRoomId

      // Count keywords
      const globalCount = Object.keys(remoteKeywords.global?.keywords || {}).length
      let roomCount = 0

      if (currentRoomId) {
        const roomData = remoteKeywords.rooms?.find(r => r.room === currentRoomId)
        roomCount = Object.keys(roomData?.keywords || {}).length
      }

      const totalApplied = globalCount + roomCount

      // Format last sync time
      const syncDate = new Date(lastSync)
      const timeStr = syncDate.toLocaleString('zh-CN', {
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
      })

      remoteKeywordsStatus.textContent = `最后同步: ${timeStr}`
      remoteKeywordsStatus.style.color = '#36a185'
      remoteKeywordsInfo.textContent = `当前房间共 ${totalApplied} 条规则(全局 ${globalCount} + 当前房间 ${roomCount})`
    }

    // Manual sync button
    syncRemoteBtn.addEventListener('click', () => {
      syncRemoteKeywords()
    })

    // ===== Keyword Testing Utilities =====

    /**
     * Tests a single keyword pair
     * @param {string} originalKeyword - The original keyword to test
     * @param {string} replacedKeyword - The replacement keyword
     * @param {number} roomId - The room ID
     * @param {string} csrfToken - The CSRF token
     * @returns {Promise<{originalBlocked: boolean, replacedBlocked: boolean|null, originalError?: string, replacedError?: string}>}
     */
    async function testKeywordPair(originalKeyword, replacedKeyword, roomId, csrfToken) {
      const originalResult = await sendDanmaku(originalKeyword, roomId, csrfToken)
      let replacedResult = null

      if (!originalResult.success) {
        // Wait 2 seconds before testing replaced keyword
        await new Promise(r => setTimeout(r, 2000))
        replacedResult = await sendDanmaku(replacedKeyword, roomId, csrfToken)
      }

      return {
        originalBlocked: !originalResult.success,
        replacedBlocked: replacedResult ? !replacedResult.success : null,
        originalError: originalResult.error,
        replacedError: replacedResult?.error,
      }
    }

    /**
     * Logs the result of a keyword test
     * @param {Object} result - Test result
     * @param {string} replacedKeyword - The replacement keyword
     * @returns {number} 1 if original was blocked, 0 otherwise
     */
    function logTestResult(result, replacedKeyword) {
      if (result.originalBlocked) {
        appendToLimitedLog(
          msgLogs,
          `  ✅ 原词被屏蔽 (错误: ${result.originalError}),测试替换词: ${replacedKeyword}`,
          maxLogLines
        )

        if (result.replacedBlocked) {
          appendToLimitedLog(msgLogs, `  ❌ 替换词也被屏蔽 (错误: ${result.replacedError})`, maxLogLines)
        } else {
          appendToLimitedLog(msgLogs, `  ✅ 替换词未被屏蔽`, maxLogLines)
        }
        return 1
      } else {
        appendToLimitedLog(msgLogs, `  ⚠️ 原词未被屏蔽,请考虑提交贡献词条`, maxLogLines)
        return 0
      }
    }

    /**
     * Gets remote keywords organized by type
     * @returns {{globalKeywords: Array<{from: string, to: string}>, roomKeywords: Array<{from: string, to: string}>}}
     */
    function getRemoteKeywords() {
      const remoteKeywords = GM_getValue('remoteKeywords', null)
      const globalKeywords = []
      const roomKeywords = []

      if (remoteKeywords) {
        // Global keywords
        const globalKw = remoteKeywords.global?.keywords || {}
        for (const [from, to] of Object.entries(globalKw)) {
          if (from) {
            globalKeywords.push({ from, to })
          }
        }

        // Room-specific keywords
        if (cachedRoomId) {
          const roomData = remoteKeywords.rooms?.find(r => r.room === cachedRoomId)
          const roomKw = roomData?.keywords || {}
          for (const [from, to] of Object.entries(roomKw)) {
            if (from) {
              roomKeywords.push({ from, to })
            }
          }
        }
      }

      return { globalKeywords, roomKeywords }
    }

    /**
     * Validates prerequisites for testing
     * @returns {Promise<{valid: boolean, roomId?: number, csrfToken?: string}>}
     */
    async function validateTestPrerequisites() {
      // Ensure we have room ID
      if (cachedRoomId === null) {
        cachedRoomId = await getRoomId()
      }
      const roomId = cachedRoomId
      const csrfToken = getCsrfToken()

      if (!csrfToken) {
        appendToLimitedLog(msgLogs, '❌ 未找到登录信息,请先登录 Bilibili', maxLogLines)
        return { valid: false }
      }

      return { valid: true, roomId, csrfToken }
    }

    /** @type {HTMLButtonElement} */
    const testRemoteBtn = document.getElementById('testRemoteBtn')
    /** @type {HTMLButtonElement} */
    const testLocalBtn = document.getElementById('testLocalBtn')

    /**
     * Tests remote keywords (global + room-specific)
     * @returns {Promise<void>}
     */
    async function testRemoteKeywords() {
      const confirmed = confirm(
        '即将测试当前直播间的云端替换词,请避免在当前直播间正在直播时进行测试,否则可能会给主播造成困扰,是否继续?'
      )

      if (!confirmed) return

      testRemoteBtn.disabled = true
      testRemoteBtn.textContent = '测试中…'

      try {
        const { valid, roomId, csrfToken } = await validateTestPrerequisites()
        if (!valid) return

        const { globalKeywords, roomKeywords } = getRemoteKeywords()
        const totalCount = globalKeywords.length + roomKeywords.length

        if (totalCount === 0) {
          appendToLimitedLog(msgLogs, '⚠️ 没有云端替换词可供测试,请先同步云端规则', maxLogLines)
          return
        }

        appendToLimitedLog(
          msgLogs,
          `🔵 开始测试云端替换词 ${totalCount} 个(全局 ${globalKeywords.length} + 房间 ${roomKeywords.length})`,
          maxLogLines
        )

        let testedCount = 0
        let totalBlockedCount = 0

        // Test global keywords
        if (globalKeywords.length > 0) {
          appendToLimitedLog(msgLogs, `\n📡 测试云端全局替换词 (${globalKeywords.length} 个)`, maxLogLines)
          let blockedCount = 0

          for (const { from, to } of globalKeywords) {
            testedCount++
            appendToLimitedLog(msgLogs, `[${testedCount}/${totalCount}] 测试: ${from}`, maxLogLines)

            const result = await testKeywordPair(from, to, roomId, csrfToken)
            const blocked = logTestResult(result, to)
            blockedCount += blocked
            totalBlockedCount += blocked

            // Wait 2 seconds before next test
            if (testedCount < totalCount) {
              await new Promise(r => setTimeout(r, 2000))
            }
          }

          appendToLimitedLog(
            msgLogs,
            `📡 全局替换词测试完成:${blockedCount}/${globalKeywords.length} 个原词被屏蔽`,
            maxLogLines
          )
        }

        // Test room-specific keywords
        if (roomKeywords.length > 0) {
          appendToLimitedLog(msgLogs, `\n🏠 测试云端房间专属替换词 (${roomKeywords.length} 个)`, maxLogLines)
          let blockedCount = 0

          for (const { from, to } of roomKeywords) {
            testedCount++
            appendToLimitedLog(msgLogs, `[${testedCount}/${totalCount}] 测试: ${from}`, maxLogLines)

            const result = await testKeywordPair(from, to, roomId, csrfToken)
            const blocked = logTestResult(result, to)
            blockedCount += blocked
            totalBlockedCount += blocked

            // Wait 2 seconds before next test
            if (testedCount < totalCount) {
              await new Promise(r => setTimeout(r, 2000))
            }
          }

          appendToLimitedLog(
            msgLogs,
            `🏠 房间专属替换词测试完成:${blockedCount}/${roomKeywords.length} 个原词被屏蔽`,
            maxLogLines
          )
        }

        appendToLimitedLog(
          msgLogs,
          `\n🔵 云端测试完成!共测试 ${totalCount} 个词,其中 ${totalBlockedCount} 个原词被屏蔽`,
          maxLogLines
        )
      } catch (error) {
        appendToLimitedLog(msgLogs, `🔴 测试出错:${error.message}`, maxLogLines)
      } finally {
        testRemoteBtn.disabled = false
        testRemoteBtn.textContent = '云端词库测试'
      }
    }

    /**
     * Tests local replacement rules
     * @returns {Promise<void>}
     */
    async function testLocalKeywords() {
      const confirmed = confirm(
        '即将测试本地替换词,请避免在当前直播间正在直播时进行测试,否则可能会给主播造成困扰,是否继续?'
      )

      if (!confirmed) return

      testLocalBtn.disabled = true
      testLocalBtn.textContent = '测试中…'

      try {
        const { valid, roomId, csrfToken } = await validateTestPrerequisites()
        if (!valid) return

        const localRules = GM_getValue('replacementRules', []).filter(rule => rule.from)

        if (localRules.length === 0) {
          appendToLimitedLog(msgLogs, '⚠️ 没有本地替换词可供测试,请先添加本地替换规则', maxLogLines)
          return
        }

        appendToLimitedLog(msgLogs, `🔵 开始测试本地替换词 ${localRules.length} 个`, maxLogLines)

        let testedCount = 0
        let blockedCount = 0

        for (const rule of localRules) {
          testedCount++
          appendToLimitedLog(msgLogs, `[${testedCount}/${localRules.length}] 测试: ${rule.from}`, maxLogLines)

          const result = await testKeywordPair(rule.from, rule.to, roomId, csrfToken)
          blockedCount += logTestResult(result, rule.to)

          // Wait 2 seconds before next test
          if (testedCount < localRules.length) {
            await new Promise(r => setTimeout(r, 2000))
          }
        }

        appendToLimitedLog(
          msgLogs,
          `\n🔵 本地测试完成!共测试 ${localRules.length} 个词,其中 ${blockedCount} 个原词被屏蔽`,
          maxLogLines
        )
      } catch (error) {
        appendToLimitedLog(msgLogs, `🔴 测试出错:${error.message}`, maxLogLines)
      } finally {
        testLocalBtn.disabled = false
        testLocalBtn.textContent = '本地词库测试'
      }
    }

    // Test button event listeners
    testRemoteBtn.addEventListener('click', () => {
      testRemoteKeywords()
    })

    testLocalBtn.addEventListener('click', () => {
      testLocalKeywords()
    })

    // Max log lines input
    /** @type {HTMLInputElement} */
    const maxLogLinesInput = document.getElementById('maxLogLinesInput')
    maxLogLinesInput.addEventListener('change', () => {
      let value = Number.parseInt(maxLogLinesInput.value, 10)
      // Validate range
      if (Number.isNaN(value) || value < 1) {
        value = 1
      } else if (value > 1000) {
        value = 1000
      }
      maxLogLinesInput.value = value.toString()
      GM_setValue('maxLogLines', value)
    })

    // Other Settings event listeners
    /** @type {HTMLInputElement} */
    const forceScrollDanmakuInput = document.getElementById('forceScrollDanmaku')
    forceScrollDanmakuInput.addEventListener('input', () => {
      GM_setValue('forceScrollDanmaku', forceScrollDanmakuInput.checked)
    })

    // Set the callback for when room ID is ready
    onRoomIdReadyCallback = updateRemoteKeywordsStatus

    // Auto-sync on load
    ;(async () => {
      const lastSync = GM_getValue('remoteKeywordsLastSync', null)
      const now = Date.now()

      // Sync if never synced or last sync was more than 30 minutes ago
      if (!lastSync || now - lastSync > SYNC_INTERVAL) {
        await syncRemoteKeywords()
      } else {
        updateRemoteKeywordsStatus()
      }
    })()

    // Auto-sync every 30 minutes
    setInterval(async () => {
      await syncRemoteKeywords()
    }, SYNC_INTERVAL)

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

/**
 * Builds the replacement map from remote and local rules
 * Priority: remote global < remote room-specific < local rules
 * @returns {void}
 */
function buildReplacementMap() {
  const map = new Map()

  // Add remote keywords
  const remoteKeywords = GM_getValue('remoteKeywords', null)
  if (remoteKeywords) {
    // Add global keywords first
    const globalKeywords = remoteKeywords.global?.keywords || {}
    for (const [from, to] of Object.entries(globalKeywords)) {
      if (from) {
        map.set(from, to)
      }
    }

    // Add room-specific keywords (override global if same key)
    if (cachedRoomId) {
      const roomData = remoteKeywords.rooms?.find(r => r.room === cachedRoomId)
      const roomKeywords = roomData?.keywords || {}
      for (const [from, to] of Object.entries(roomKeywords)) {
        if (from) {
          map.set(from, to)
        }
      }
    }
  }

  // Add local rules (override remote if same key)
  const localRules = GM_getValue('replacementRules', [])
  for (const rule of localRules) {
    if (rule.from) {
      map.set(rule.from, rule.to)
    }
  }

  replacementMap = map
}

/**
 * Applies all replacement rules to the given text
 * Uses cached replacement map for efficiency
 * @param {string} text - The text to apply replacements to
 * @returns {string} The text with all replacements applied
 */
function applyReplacements(text) {
  // Build map on first use
  if (replacementMap === null) {
    buildReplacementMap()
  }

  let result = text
  for (const [from, to] of replacementMap.entries()) {
    result = result.split(from).join(to)
  }

  return result
}

/**
 * Gets the CSRF token from browser cookies
 * @returns {string|undefined} The CSRF token (bili_jct), or undefined if not found
 */
function getCsrfToken() {
  return document.cookie
    .split(';')
    .map(c => c.trim())
    .find(c => c.startsWith('bili_jct='))
    ?.split('bili_jct=')[1]
}

/**
 * Gets the room ID for a Bilibili live room
 * @param {string} [url] - The room URL (defaults to current page URL)
 * @returns {Promise<number>} The room ID
 */
async function getRoomId(url = window.location.href) {
  const shortUid = extractRoomNumber(url)

  try {
    const room = await fetch(`${BASE_URL.BILIBILI_ROOM_INIT}?id=${shortUid}`, {
      method: 'GET',
      credentials: 'include',
    })

    if (!room.ok) {
      throw new Error(`HTTP ${room.status}: ${room.statusText}`)
    }

    /** @type {{data: {room_id: number}}} */
    const roomData = await room.json()
    return roomData.data.room_id
  } catch (error) {
    console.error('Failed to get room ID:', error)
    throw error
  }
}

/**
 * Sends a single danmaku message to Bilibili live room
 * @param {string} message - The message text to send
 * @param {number} roomId - The room ID to send the message to
 * @param {string} csrfToken - The CSRF token for authentication
 * @returns {Promise<{success: boolean, message: string, error?: string}>} Result of the send operation
 */
async function sendDanmaku(message, roomId, csrfToken) {
  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)

  try {
    // Add silly queries😁
    let query = ''
    if (cachedWbiKeys) {
      query = encodeWbi(
        {
          web_location: getSpmPrefix(),
        },
        cachedWbiKeys
      )
    }

    const url = `${BASE_URL.BILIBILI_MSG_SEND}?${query}`
    const resp = await fetch(url, {
      method: 'POST',
      credentials: 'include',
      body: form,
    })

    /** @type {{message?: string, code?: number}} */
    const json = await resp.json()

    if (json.message) {
      return {
        success: false,
        message: message,
        error: json.message,
      }
    }

    return {
      success: true,
      message: message,
    }
  } catch (error) {
    return {
      success: false,
      message: message,
      error: error.message,
    }
  }
}

/**
 * Main loop function that handles sending messages to Bilibili live chat
 * Continuously checks if sendMsg is true and sends queued messages with configured intervals
 * @returns {Promise<void>}
 */
async function loop() {
  let count = 0
  /** @type {HTMLTextAreaElement} */
  const msgLogs = document.getElementById('msgLogs')
  /** @type {number} */
  const maxLogLines = GM_getValue('maxLogLines')

  // Fetch and cache room ID on first call
  if (cachedRoomId === null) {
    try {
      cachedRoomId = await getRoomId()
      buildReplacementMap() // Rebuild map with room-specific keywords
      // Update remote keywords status now that we have the room ID
      if (onRoomIdReadyCallback) {
        onRoomIdReadyCallback()
      }

      // Fetch danmaku config on script startup
      await waitForWbiKeys()
      if (cachedWbiKeys) {
        try {
          const configQuery = encodeWbi(
            {
              room_id: String(cachedRoomId),
              web_location: getSpmPrefix(),
            },
            cachedWbiKeys
          )
          const configUrl = `${BASE_URL.BILIBILI_GET_DM_CONFIG}?${configQuery}`
          /** @type {DanmakuConfigResponse} */
          const configResp = await fetch(configUrl, {
            method: 'GET',
            credentials: 'include',
          }).then(r => r.json())

          // Extract available colors from all groups
          if (configResp?.data?.group) {
            const colors = []
            for (const group of configResp.data.group) {
              for (const color of group.color) {
                // Only include enabled colors (status === 1)
                if (color.status === 1) {
                  colors.push(`0x${color.color_hex}`)
                }
              }
            }
            if (colors.length > 0) {
              availableDanmakuColors = colors
              console.log('[LAPLACE Chatterbox Helper] Available colors:', colors)
            }
          }
        } catch {
          // Silently fail - config fetch is non-critical
        }
      }

      // Initialize config on script startup (if enabled)
      const forceScrollDanmaku = GM_getValue('forceScrollDanmaku')
      if (forceScrollDanmaku) {
        const initCsrfToken = getCsrfToken()
        if (initCsrfToken) {
          const initConfigForm = new FormData()
          initConfigForm.append('room_id', String(cachedRoomId))
          initConfigForm.append('mode', '1')
          initConfigForm.append('csrf_token', initCsrfToken)
          initConfigForm.append('csrf', initCsrfToken)
          initConfigForm.append('visit_id', '')

          try {
            await fetch(BASE_URL.BILIBILI_MSG_CONFIG, {
              method: 'POST',
              credentials: 'include',
              body: initConfigForm,
            })
          } catch {
            // Silently fail - config init is non-critical
          }
        }
      }
    } catch (error) {
      appendToLimitedLog(msgLogs, `❌ 获取房间ID失败: ${error.message}`, maxLogLines)
      await new Promise(r => setTimeout(r, 5000))
      return // Exit and let the loop restart
    }
  }
  const roomId = cachedRoomId
  const csrfToken = getCsrfToken()

  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
      }

      /** @type {number} */
      const msgSendInterval = GM_getValue('msgSendInterval')
      /** @type {boolean} */
      const enableRandomColor = GM_getValue('randomColor')
      /** @type {boolean} */
      const enableRandomInterval = GM_getValue('randomInterval')
      /** @type {boolean} */
      const enableRandomChar = GM_getValue('randomChar')
      const Msg = processMessages(currentTemplate, GM_getValue('maxLength'), enableRandomChar)

      for (const message of Msg) {
        if (sendMsg) {
          // Apply text replacements
          const originalMessage = message
          const processedMessage = applyReplacements(message)
          const wasReplaced = originalMessage !== processedMessage

          if (enableRandomColor) {
            // Use available colors from API or fallback to hardcoded set
            const colorSet = availableDanmakuColors || [
              '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', '')

            try {
              await fetch(BASE_URL.BILIBILI_MSG_CONFIG, {
                method: 'POST',
                credentials: 'include',
                body: configForm,
              })
            } catch {
              // Silently fail - color update is non-critical
            }
          }

          const result = await sendDanmaku(processedMessage, roomId, csrfToken)
          const displayMsg = wasReplaced ? `${originalMessage} → ${processedMessage}` : processedMessage
          const logMessage = result.success
            ? `✅ 自动: ${displayMsg}`
            : `❌ 自动: ${displayMsg},原因:${result.error}。`

          appendToLimitedLog(msgLogs, logMessage, maxLogLines)

          const resolvedRandomInterval = enableRandomInterval ? Math.floor(Math.random() * 500) : 0
          await new Promise(r => setTimeout(r, msgSendInterval * 1000 - resolvedRandomInterval))
        }
      }

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