// ==UserScript== // @name Huya/Douyu Danmu Web Sender // @namespace https://local.codex/danmu-web-sender // @version 1.2.1 // @description 网页端虎牙/斗鱼定时弹幕发送助手 // @match https://www.huya.com/* // @match https://huya.com/* // @match https://www.douyu.com/* // @match https://douyu.com/* // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; const STORAGE_KEY = `codex.danmuWebSender.v1.${location.hostname}`; const MIN_INTERVAL_SECONDS = 1; const DEFAULT_NOVEL_CHUNK_LENGTH = 28; const DEFAULT_SETTINGS = { messages: "大家好\n主播加油\n666", intervalMode: "fixed", intervalSeconds: 30, jitterSeconds: 5, intervalList: "30,45,60", novelChunkLength: DEFAULT_NOVEL_CHUNK_LENGTH, maxSendCount: 0, loopMessages: true, randomOrder: false, sendMode: "auto", contentMode: "manual", }; const state = { settings: loadSettings(), running: false, timerId: 0, targetElement: null, lastEditable: null, sentCount: 0, messageIndex: 0, intervalIndex: 0, randomBag: [], captureMode: false, }; const host = document.createElement("div"); host.id = "codex-danmu-web-sender"; document.documentElement.appendChild(host); const root = host.attachShadow({ mode: "open" }); root.innerHTML = `
弹幕定时发送
先点击网页里的弹幕输入框,再点“开始”。
不会绕过登录、验证码、禁言或平台限制;间隔时间由你自己设置,过短可能触发平台限制。
`; const ui = { panel: root.querySelector(".panel"), dragHandle: root.getElementById("dragHandle"), panelBody: root.getElementById("panelBody"), toggleButton: root.getElementById("toggleButton"), messagesInput: root.getElementById("messagesInput"), intervalModeInput: root.getElementById("intervalModeInput"), intervalInput: root.getElementById("intervalInput"), jitterInput: root.getElementById("jitterInput"), intervalListInput: root.getElementById("intervalListInput"), maxInput: root.getElementById("maxInput"), novelChunkInput: root.getElementById("novelChunkInput"), sendModeInput: root.getElementById("sendModeInput"), loopInput: root.getElementById("loopInput"), randomInput: root.getElementById("randomInput"), captureButton: root.getElementById("captureButton"), useLastButton: root.getElementById("useLastButton"), importButton: root.getElementById("importButton"), novelImportButton: root.getElementById("novelImportButton"), fileInput: root.getElementById("fileInput"), novelFileInput: root.getElementById("novelFileInput"), startButton: root.getElementById("startButton"), stopButton: root.getElementById("stopButton"), saveButton: root.getElementById("saveButton"), statusBox: root.getElementById("statusBox"), }; fillForm(); bindUiEvents(); bindDocumentWatchers(); makePanelDraggable(); function loadSettings() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); return { ...DEFAULT_SETTINGS, ...saved }; } catch (error) { return { ...DEFAULT_SETTINGS }; } } function saveSettings(overrides = {}) { state.settings = normalizeSettings({ ...readForm(), ...overrides }); localStorage.setItem(STORAGE_KEY, JSON.stringify(state.settings)); fillForm(); return state.settings; } function normalizeSettings(settings) { const contentMode = settings.contentMode === "novel" ? "novel" : "manual"; const intervalMode = ["fixed", "randomRange", "sequence"].includes(settings.intervalMode) ? settings.intervalMode : "fixed"; const intervalList = String(settings.intervalList || DEFAULT_SETTINGS.intervalList).trim(); return { ...settings, contentMode, intervalMode, intervalSeconds: Math.max( MIN_INTERVAL_SECONDS, Math.floor(readNumber(settings.intervalSeconds, DEFAULT_SETTINGS.intervalSeconds)) ), jitterSeconds: Math.max(0, Math.floor(readNumber(settings.jitterSeconds, 0))), intervalList: intervalMode === "sequence" ? normalizeIntervalList(intervalList) : intervalList, novelChunkLength: clamp( Math.floor(readNumber(settings.novelChunkLength, DEFAULT_NOVEL_CHUNK_LENGTH)), 8, 60 ), }; } function fillForm() { ui.messagesInput.value = state.settings.messages; ui.intervalModeInput.value = state.settings.intervalMode; ui.intervalInput.value = String(state.settings.intervalSeconds); ui.jitterInput.value = String(state.settings.jitterSeconds); ui.intervalListInput.value = state.settings.intervalList; ui.maxInput.value = String(state.settings.maxSendCount); ui.novelChunkInput.value = String(state.settings.novelChunkLength); ui.sendModeInput.value = state.settings.sendMode; ui.loopInput.checked = Boolean(state.settings.loopMessages); ui.randomInput.checked = Boolean(state.settings.randomOrder); updateIntervalControls(); } function readForm() { const intervalMode = ["fixed", "randomRange", "sequence"].includes(ui.intervalModeInput.value) ? ui.intervalModeInput.value : "fixed"; return { messages: ui.messagesInput.value, intervalMode, intervalSeconds: Math.max( MIN_INTERVAL_SECONDS, Math.floor(readNumber(ui.intervalInput.value, 30)) ), jitterSeconds: Math.max(0, Math.floor(readNumber(ui.jitterInput.value, 0))), intervalList: ui.intervalListInput.value.trim(), novelChunkLength: clamp( Math.floor(readNumber(ui.novelChunkInput.value, DEFAULT_NOVEL_CHUNK_LENGTH)), 8, 60 ), maxSendCount: Math.max(0, Math.floor(readNumber(ui.maxInput.value, 0))), loopMessages: ui.loopInput.checked, randomOrder: ui.randomInput.checked, sendMode: ui.sendModeInput.value, contentMode: state.settings.contentMode || DEFAULT_SETTINGS.contentMode, }; } function readNumber(value, fallback) { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function bindUiEvents() { ui.startButton.addEventListener("click", start); ui.stopButton.addEventListener("click", stop); ui.saveButton.addEventListener("click", () => { saveSettings(); setStatus("已保存设置。"); }); ui.toggleButton.addEventListener("click", () => { const isHidden = ui.panelBody.classList.toggle("hidden"); ui.toggleButton.textContent = isHidden ? "展开" : "收起"; }); ui.captureButton.addEventListener("click", () => { state.captureMode = true; setStatus("请在 10 秒内点击网页里的弹幕输入框。"); setTimeout(() => { if (state.captureMode) { state.captureMode = false; setStatus("捕获结束。也可以先点弹幕输入框,再点“开始”。"); } }, 10000); }); ui.useLastButton.addEventListener("click", () => { if (!state.lastEditable || !document.contains(state.lastEditable)) { setStatus("还没有记住输入框,请先点击网页里的弹幕输入框。"); return; } state.targetElement = state.lastEditable; setStatus(`已使用输入框: ${describeElement(state.targetElement)}`); }); ui.importButton.addEventListener("click", () => ui.fileInput.click()); ui.novelImportButton.addEventListener("click", () => ui.novelFileInput.click()); ui.fileInput.addEventListener("change", importTextFile); ui.novelFileInput.addEventListener("change", importNovelTextFile); ui.intervalModeInput.addEventListener("change", updateIntervalControls); } function updateIntervalControls() { const mode = ui.intervalModeInput.value; ui.jitterInput.disabled = mode === "fixed" || mode === "sequence"; ui.intervalListInput.disabled = mode !== "sequence"; if (mode === "fixed") { ui.intervalInput.title = "固定等待这个秒数后发送下一条。"; ui.jitterInput.title = "固定间隔模式不会使用随机秒。"; ui.intervalListInput.title = "只有按秒数列表模式会使用这里。"; } else if (mode === "randomRange") { ui.intervalInput.title = "随机区间的最短等待秒数。"; ui.jitterInput.title = "会在最短等待上额外随机增加 0 到这个秒数。"; ui.intervalListInput.title = "只有按秒数列表模式会使用这里。"; } else { ui.intervalInput.title = "秒数列表无效时会回退使用这里。"; ui.jitterInput.title = "按秒数列表模式不会使用随机秒。"; ui.intervalListInput.title = "用逗号、空格或换行分隔,例如 30,45,60。"; } } function bindDocumentWatchers() { document.addEventListener("focusin", rememberEditable, true); document.addEventListener("click", rememberEditable, true); } function rememberEditable(event) { if (event.composedPath && event.composedPath().includes(host)) { return; } const editable = normalizeEditable(event.target); if (!editable) { return; } state.lastEditable = editable; if (state.captureMode) { state.captureMode = false; state.targetElement = editable; setStatus(`已捕获输入框: ${describeElement(editable)}`); } else if (!state.running) { setStatus(`已记住输入框: ${describeElement(editable)}`); } } async function importTextFile() { const file = ui.fileInput.files && ui.fileInput.files[0]; if (!file) { return; } try { ui.messagesInput.value = await readFileText(file); saveSettings({ contentMode: "manual" }); setStatus(`已导入: ${file.name}`); } catch (error) { setStatus("导入失败,请直接复制内容到文本框。"); } finally { ui.fileInput.value = ""; } } async function importNovelTextFile() { const file = ui.novelFileInput.files && ui.novelFileInput.files[0]; if (!file) { return; } try { const text = await readFileText(file); const chunkLength = clamp( Math.floor(readNumber(ui.novelChunkInput.value, DEFAULT_NOVEL_CHUNK_LENGTH)), 8, 60 ); const chunks = splitNovelText(text, chunkLength); if (!chunks.length) { setStatus("小说文件没有可发送内容。"); return; } ui.messagesInput.value = chunks.join("\n"); ui.novelChunkInput.value = String(chunkLength); ui.loopInput.checked = false; ui.randomInput.checked = false; saveSettings({ contentMode: "novel" }); setStatus(`已按顺序导入小说 ${chunks.length} 段: ${file.name}`); } catch (error) { setStatus("小说导入失败,请确认是 TXT 文本文件。"); } finally { ui.novelFileInput.value = ""; } } function start() { const settings = saveSettings(); const messages = parseMessages(settings.messages); if (!messages.length) { setStatus("没有可发送内容,请先填写弹幕内容。"); return; } const target = ensureTargetElement(); if (!target) { setStatus("没有找到弹幕输入框。请先点击输入框,或点“捕获输入框”。"); return; } stop(false); state.running = true; state.sentCount = 0; state.messageIndex = 0; state.intervalIndex = 0; state.randomBag = []; setStatus("已启动,3 秒后发送第一条。"); scheduleNext(3000); } function stop(showStatus = true) { if (state.timerId) { clearTimeout(state.timerId); state.timerId = 0; } state.running = false; if (showStatus) { setStatus(`已停止,本次共发送 ${state.sentCount} 条。`); } } function scheduleNext(delayMs) { state.timerId = window.setTimeout(sendTick, delayMs); } async function sendTick() { if (!state.running) { return; } const settings = state.settings; const messages = parseMessages(settings.messages); if (settings.maxSendCount > 0 && state.sentCount >= settings.maxSendCount) { stop(false); setStatus(`已达到最多 ${settings.maxSendCount} 条,自动停止。`); return; } const message = nextMessage(messages, settings); if (!message) { stop(false); setStatus(`消息列表发送完毕,本次共发送 ${state.sentCount} 条。`); return; } const ok = await sendMessage(message, settings); if (!ok) { stop(false); setStatus("发送失败:找不到可用输入框或发送按钮。"); return; } state.sentCount += 1; const nextDelay = nextDelayMs(settings); setStatus( `已发送第 ${state.sentCount} 条。下次约 ${Math.round(nextDelay / 1000)} 秒后发送。` ); scheduleNext(nextDelay); } function parseMessages(text) { return String(text || "") .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")); } async function readFileText(file) { const buffer = await file.arrayBuffer(); const decoders = [ () => new TextDecoder("utf-8", { fatal: true }).decode(buffer), () => new TextDecoder("gb18030").decode(buffer), () => new TextDecoder("utf-8").decode(buffer), ]; for (const decode of decoders) { try { return decode().replace(/^\uFEFF/, ""); } catch (error) { // Try the next common novel TXT encoding. } } return ""; } function splitNovelText(text, maxLength) { const normalized = String(text || "") .replace(/\r\n?/g, "\n") .split(/\n+/) .map((line) => line.replace(/\s+/g, " ").trim()) .filter(Boolean); const chunks = []; for (const paragraph of normalized) { chunks.push(...packNovelParagraph(paragraph, maxLength)); } return chunks; } function packNovelParagraph(paragraph, maxLength) { const sentences = paragraph.match(/[^。!?!?;;]+[。!?!?;;]?/g) || [paragraph]; const chunks = []; let current = ""; for (const sentence of sentences) { const trimmed = sentence.trim(); if (!trimmed) { continue; } if (countChars(trimmed) > maxLength) { if (current) { chunks.push(current); current = ""; } chunks.push(...splitLongText(trimmed, maxLength)); continue; } if (!current || countChars(current + trimmed) <= maxLength) { current += trimmed; } else { chunks.push(current); current = trimmed; } } if (current) { chunks.push(current); } return chunks; } function splitLongText(text, maxLength) { const chars = Array.from(text); const chunks = []; for (let index = 0; index < chars.length; index += maxLength) { chunks.push(chars.slice(index, index + maxLength).join("")); } return chunks; } function countChars(text) { return Array.from(text).length; } function nextMessage(messages, settings) { if (!messages.length) { return ""; } if (settings.randomOrder) { if (!state.randomBag.length) { if (!settings.loopMessages && state.messageIndex > 0) { return ""; } state.randomBag = shuffle(messages); state.messageIndex += 1; } return state.randomBag.shift() || ""; } if (state.messageIndex >= messages.length) { if (!settings.loopMessages) { return ""; } state.messageIndex = 0; } const message = messages[state.messageIndex]; state.messageIndex += 1; return message; } function shuffle(items) { const result = items.slice(); for (let index = result.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(Math.random() * (index + 1)); [result[index], result[swapIndex]] = [result[swapIndex], result[index]]; } return result; } function nextDelayMs(settings) { const interval = Math.max(MIN_INTERVAL_SECONDS, settings.intervalSeconds); if (settings.intervalMode === "sequence") { const values = parseIntervalList(settings.intervalList); if (values.length) { const delay = values[state.intervalIndex % values.length]; state.intervalIndex += 1; return Math.round(delay * 1000); } } if (settings.intervalMode === "randomRange") { const jitter = Math.max(0, settings.jitterSeconds); return Math.round((interval + Math.random() * jitter) * 1000); } return Math.round(interval * 1000); } function parseIntervalList(text) { return String(text || "") .split(/[\s,,;;、]+/) .map((value) => Math.floor(Number(value))) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.max(MIN_INTERVAL_SECONDS, value)); } function normalizeIntervalList(text) { const values = parseIntervalList(text); if (!values.length) { return DEFAULT_SETTINGS.intervalList; } return values.join(","); } async function sendMessage(message, settings) { const target = ensureTargetElement(); if (!target) { return false; } target.focus(); fillEditable(target, message); await delay(140); if (settings.sendMode === "button" || settings.sendMode === "auto") { const button = findSendButton(target); if (button) { button.click(); return true; } } if (settings.sendMode === "enter" || settings.sendMode === "auto") { pressEnter(target); return true; } return false; } function delay(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function ensureTargetElement() { if (state.targetElement && document.contains(state.targetElement)) { return state.targetElement; } if (state.lastEditable && document.contains(state.lastEditable)) { state.targetElement = state.lastEditable; return state.targetElement; } state.targetElement = guessDanmuInput(); return state.targetElement; } function normalizeEditable(element) { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return null; } const direct = element.closest( 'textarea,input:not([type="hidden"]),[contenteditable="true"],[role="textbox"]' ); if (!direct || direct.closest("#codex-danmu-web-sender")) { return null; } if (direct.matches("input,textarea")) { if (direct.disabled || direct.readOnly) { return null; } const type = (direct.getAttribute("type") || "text").toLowerCase(); if (["button", "checkbox", "file", "hidden", "radio", "submit"].includes(type)) { return null; } } return direct; } function guessDanmuInput() { const selectors = [ 'textarea[placeholder*="弹幕"]', 'textarea[placeholder*="聊天"]', 'input[placeholder*="弹幕"]', 'input[placeholder*="聊天"]', '[contenteditable="true"][placeholder*="弹幕"]', '[contenteditable="true"][placeholder*="聊天"]', '[role="textbox"]', '[contenteditable="true"]', "textarea", 'input[type="text"]', ]; const candidates = uniqueElements(selectors.flatMap((selector) => query(selector))); const scored = candidates .map((element) => ({ element, score: scoreInputCandidate(element) })) .filter((item) => item.score > 0 && isVisible(item.element)) .sort((a, b) => b.score - a.score); return scored[0] ? scored[0].element : null; } function query(selector) { try { return Array.from(document.querySelectorAll(selector)); } catch (error) { return []; } } function uniqueElements(items) { return Array.from(new Set(items)); } function scoreInputCandidate(element) { if (!normalizeEditable(element)) { return 0; } const text = [ element.getAttribute("placeholder"), element.getAttribute("aria-label"), element.getAttribute("title"), element.id, element.className, ] .filter(Boolean) .join(" "); let score = 1; if (/弹幕|聊天|发言|输入|danmu|barrage|chat|say/i.test(text)) { score += 8; } if (/send|发送/i.test(text)) { score += 2; } if (element.isContentEditable) { score += 1; } return score; } function fillEditable(element, text) { element.focus(); if (element.matches("textarea,input")) { const prototype = element.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); if (descriptor && descriptor.set) { descriptor.set.call(element, text); } else { element.value = text; } element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); return; } if (element.isContentEditable || element.getAttribute("role") === "textbox") { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); const inserted = document.execCommand && document.execCommand("insertText", false, text); if (!inserted) { element.textContent = text; } element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text })); } } function pressEnter(element) { ["keydown", "keypress", "keyup"].forEach((type) => { element.dispatchEvent( new KeyboardEvent(type, { bubbles: true, cancelable: true, key: "Enter", code: "Enter", keyCode: 13, which: 13, }) ); }); } function findSendButton(inputElement) { const containers = [ inputElement.closest("form"), inputElement.closest('[class*="chat"],[class*="Chat"],[class*="danmu"],[class*="Danmu"],[class*="barrage"],[class*="Barrage"]'), inputElement.parentElement, document.body, ].filter(Boolean); for (const container of containers) { const button = bestSendButton(container, inputElement); if (button) { return button; } } return null; } function bestSendButton(container, inputElement) { const candidates = Array.from( container.querySelectorAll( [ "button", '[role="button"]', 'input[type="button"]', 'input[type="submit"]', "a", '[class*="send"]', '[class*="Send"]', '[class*="btn"]', '[class*="Btn"]', '[class*="button"]', '[class*="Button"]', '[class*="submit"]', '[class*="Submit"]', ].join(",") ) ); const scored = candidates .filter((element) => element !== inputElement && isVisible(element) && !isDisabled(element)) .map((element) => ({ element, score: scoreSendButton(element), distance: distanceBetween(element, inputElement), })) .filter((item) => item.score >= 6) .sort((a, b) => b.score - a.score || a.distance - b.distance); return scored[0] ? scored[0].element : null; } function scoreSendButton(element) { const tagName = element.tagName.toLowerCase(); const classAndId = `${element.id || ""} ${element.className || ""}`; const visibleText = String(element.innerText || element.textContent || "").trim(); const nativeButton = /^(button|a|input)$/.test(tagName) || element.getAttribute("role") === "button"; if (!nativeButton && visibleText.length > 20 && !/send|btn|button|submit/i.test(classAndId)) { return 0; } const text = [ visibleText, element.getAttribute("aria-label"), element.getAttribute("title"), element.getAttribute("value"), element.id, element.className, ] .filter(Boolean) .join(" "); let score = 0; if (/发送|send|submit/i.test(text)) { score += 8; } if (/弹幕|danmu|barrage|chat/i.test(text)) { score += 3; } if (/button|btn|submit/i.test(text)) { score += 2; } return score; } function isDisabled(element) { return Boolean( element.disabled || element.getAttribute("aria-disabled") === "true" || /disabled|disable|forbid/i.test(String(element.className)) ); } function isVisible(element) { if (!element || !element.getBoundingClientRect) { return false; } const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return ( rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) !== 0 ); } function distanceBetween(a, b) { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); const ax = ar.left + ar.width / 2; const ay = ar.top + ar.height / 2; const bx = br.left + br.width / 2; const by = br.top + br.height / 2; return Math.hypot(ax - bx, ay - by); } function describeElement(element) { const label = element.getAttribute("placeholder") || element.getAttribute("aria-label") || element.getAttribute("title") || element.id || element.tagName.toLowerCase(); return String(label).slice(0, 28); } function setStatus(text) { ui.statusBox.textContent = text; } function makePanelDraggable() { let startX = 0; let startY = 0; let startRight = 0; let startTop = 0; let dragging = false; ui.dragHandle.addEventListener("pointerdown", (event) => { if (event.target === ui.toggleButton) { return; } dragging = true; startX = event.clientX; startY = event.clientY; const rect = ui.panel.getBoundingClientRect(); startRight = window.innerWidth - rect.right; startTop = rect.top; ui.dragHandle.setPointerCapture(event.pointerId); }); ui.dragHandle.addEventListener("pointermove", (event) => { if (!dragging) { return; } const nextRight = Math.max(8, startRight - (event.clientX - startX)); const nextTop = Math.max(8, startTop + (event.clientY - startY)); ui.panel.style.right = `${nextRight}px`; ui.panel.style.top = `${nextTop}px`; }); ui.dragHandle.addEventListener("pointerup", (event) => { dragging = false; ui.dragHandle.releasePointerCapture(event.pointerId); }); } })();