// ==UserScript== // @name sb6657 & douyuEx 弹幕一键装填工具 // @namespace http://tampermonkey.net/ // @version 26.6.1 // @description 从 sb6657 弹幕库筛选内容并填入 douyuEx 的弹幕发送小助手 !! 你必须已经安装 斗鱼ex (https://douyuex.com) // @author kbd // @match *://*.douyu.com/* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect sb6657-database.pages.dev // @license MIT // @noframes // ==/UserScript== (function () { "use strict"; const DATABASE_URL = "https://sb6657-database.pages.dev/memes.jsonl"; const ROOT_ID = "douyuex-meme-library-filter"; const TEXTAREA_ID = "bloop__textarea"; const START_SEND_ID = "bloop__checkbox_startSend"; let memeLibrary = null; let libraryRequest = null; function createElement(tagName, options) { const element = document.createElement(tagName); const settings = options || {}; if (settings.className) element.className = settings.className; if (settings.text !== undefined) element.textContent = settings.text; if (settings.type) element.type = settings.type; if (settings.placeholder) element.placeholder = settings.placeholder; if (settings.value !== undefined) element.value = settings.value; if (settings.title) element.title = settings.title; if (settings.attributes) { Object.keys(settings.attributes).forEach((name) => { element.setAttribute(name, settings.attributes[name]); }); } if (settings.style) { Object.assign(element.style, settings.style); } return element; } function setStatus(statusElement, message, tone) { const colors = { info: "#6b7280", success: "#15803d", warning: "#b45309", error: "#dc2626", }; statusElement.textContent = message; statusElement.style.color = colors[tone] || colors.info; } function setBusy(applyButton, refreshButton, isBusy) { applyButton.disabled = isBusy; refreshButton.disabled = isBusy; applyButton.style.opacity = isBusy ? "0.65" : "1"; refreshButton.style.opacity = isBusy ? "0.65" : "1"; } function requestJsonl() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: DATABASE_URL, responseType: "text", timeout: 30000, onload(response) { if (response.status < 200 || response.status >= 300) { reject(new Error(`下载失败(HTTP ${response.status})`)); return; } resolve(response.responseText || ""); }, onerror() { reject(new Error("下载失败,请检查网络后重试。")); }, ontimeout() { reject(new Error("下载超时,请稍后重试。")); }, }); }); } function parseMemeLibrary(rawText) { const records = []; let skippedLines = 0; rawText.split(/\r?\n/).forEach((line) => { if (!line.trim()) return; try { const row = JSON.parse(line); const contentLength = row.content_length; const content = typeof row.content === "string" ? row.content.replace(/[\r\n]+/g, " ").trim() : ""; const submittedAt = typeof row.submitted_at === "string" ? Date.parse(row.submitted_at) : Number.NaN; if (!content || typeof contentLength !== "number" || !Number.isSafeInteger(contentLength) || contentLength < 0) { skippedLines += 1; return; } records.push({ content, contentLength, submittedAt: Number.isNaN(submittedAt) ? null : submittedAt, }); } catch (error) { skippedLines += 1; } }); if (!records.length) { throw new Error("弹幕库中没有可用的弹幕。"); } return { records, skippedLines }; } async function loadLibrary(forceRefresh, statusElement) { if (!forceRefresh && memeLibrary) return memeLibrary; if (libraryRequest) return libraryRequest; setStatus(statusElement, forceRefresh ? "正在刷新弹幕库…" : "正在下载弹幕库…", "info"); libraryRequest = requestJsonl() .then(parseMemeLibrary) .then((parsed) => { memeLibrary = parsed.records; const suffix = parsed.skippedLines ? `,跳过 ${parsed.skippedLines} 条无效记录` : ""; setStatus(statusElement, `弹幕库已加载:${memeLibrary.length} 条${suffix}。`, "success"); return memeLibrary; }) .catch((error) => { setStatus(statusElement, error.message || "弹幕库加载失败。", "error"); throw error; }) .finally(() => { libraryRequest = null; }); return libraryRequest; } function readLimit(input, label) { const value = input.value.trim(); if (!value) return { value: null }; if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) { return { error: `${label}必须是非负整数。` }; } return { value: Number(value) }; } function splitKeywords(value) { return [...new Set(value .split(",") .map((keyword) => keyword.trim().toLowerCase()) .filter(Boolean))]; } function readFilters(keywordInput, excludeKeywordInput, minLengthInput, maxLengthInput, afterTimeInput) { const minLength = readLimit(minLengthInput, "最小长度"); const maxLength = readLimit(maxLengthInput, "最大长度"); const afterTimeValue = afterTimeInput.value.trim(); if (minLength.error) return { error: minLength.error }; if (maxLength.error) return { error: maxLength.error }; if (minLength.value !== null && maxLength.value !== null && minLength.value > maxLength.value) { return { error: "最小长度不能大于最大长度。" }; } const afterTime = afterTimeValue ? Date.parse(afterTimeValue) : null; if (afterTimeValue && Number.isNaN(afterTime)) { return { error: "筛选时间无效。" }; } return { keywords: splitKeywords(keywordInput.value), excludeKeywords: splitKeywords(excludeKeywordInput.value), minLength: minLength.value === null ? 0 : minLength.value, maxLength: maxLength.value === null ? Infinity : maxLength.value, afterTime, }; } function filterMemes(records, filters) { return records.filter((record) => { const normalizedContent = record.content.toLowerCase(); const hasKeyword = !filters.keywords.length || filters.keywords.some((keyword) => normalizedContent.includes(keyword)); const avoidsExcludedKeyword = !filters.excludeKeywords.some((keyword) => normalizedContent.includes(keyword)); const isAfterTime = filters.afterTime === null || (record.submittedAt !== null && record.submittedAt > filters.afterTime); return hasKeyword && avoidsExcludedKeyword && record.contentLength >= filters.minLength && record.contentLength <= filters.maxLength && isAfterTime; }); } function stopActiveLoop() { const startCheckbox = document.getElementById(START_SEND_ID); if (!startCheckbox || !startCheckbox.checked) return false; startCheckbox.click(); return true; } function isTiangouModeEnabled() { const tiangouCheckbox = document.getElementById("bloop__checkbox_tiangou"); return Boolean(tiangouCheckbox && tiangouCheckbox.checked); } function applyResults(textarea, matches) { textarea.value = matches.map((record) => record.content).join("\n"); textarea.dispatchEvent(new Event("input", { bubbles: true })); } function insertFilterPanel(textarea) { if (document.getElementById(ROOT_ID)) return true; const root = createElement("div", { attributes: { id: ROOT_ID }, style: { position: "absolute", left: "0", right: "0", bottom: "100%", zIndex: "1", boxSizing: "border-box", maxHeight: "calc(100vh - 200px)", overflowY: "auto", backgroundColor: "rgba(255, 255, 255, 0.98)", borderBottom: "1px solid rgba(128, 128, 128, 0.35)", padding: "0 0 9px", fontSize: "13px", lineHeight: "1.5", }, }); const title = createElement("div", { text: "sb6657 弹幕库装填器🚀", style: { fontWeight: "600", marginBottom: "6px" }, }); const fields = createElement("div", { style: { display: "flex", flexWrap: "wrap", gap: "6px", alignItems: "center" }, }); const keywordInput = createElement("input", { type: "text", placeholder: "关键词(英文逗号分隔,可留空)", title: "按弹幕正文包含的关键词筛选", attributes: { "aria-label": "关键词" }, style: { boxSizing: "border-box", flex: "1 1 145px", minWidth: "120px" }, }); const excludeKeywordInput = createElement("input", { type: "text", placeholder: "排除关键词(英文逗号分隔,可留空)", title: "排除正文包含任意一个关键词的弹幕", attributes: { "aria-label": "排除关键词" }, style: { boxSizing: "border-box", flex: "1 1 145px", minWidth: "120px" }, }); const minLengthInput = createElement("input", { type: "number", placeholder: "最小长度", title: "留空表示不限", attributes: { min: "0", step: "1", inputmode: "numeric", "aria-label": "最小长度" }, style: { boxSizing: "border-box", width: "82px" }, }); const maxLengthInput = createElement("input", { type: "number", placeholder: "最大长度", title: "留空表示不限", attributes: { min: "0", step: "1", inputmode: "numeric", "aria-label": "最大长度" }, style: { boxSizing: "border-box", width: "82px" }, }); const afterTimeInput = createElement("input", { type: "datetime-local", title: "只保留晚于此时间点提交的弹幕,留空表示不限", attributes: { step: "1", "aria-label": "晚于此时间" }, style: { boxSizing: "border-box", width: "190px" }, }); const applyButton = createElement("button", { type: "button", text: "筛选并替换" }); const refreshButton = createElement("button", { type: "button", text: "刷新库" }); const status = createElement("div", { text: "首次筛选时将下载弹幕库。", attributes: { "aria-live": "polite" }, style: { marginTop: "6px", minHeight: "20px", color: "#6b7280" }, }); fields.append(keywordInput, excludeKeywordInput, minLengthInput, maxLengthInput, afterTimeInput, applyButton, refreshButton); root.append(title, fields, status); const assistantPanel = textarea.closest(".bloop") || textarea.parentElement; assistantPanel.insertBefore(root, assistantPanel.firstChild); if (assistantPanel.classList.contains("bloop")) { assistantPanel.style.height = ""; assistantPanel.style.bottom = ""; } applyButton.addEventListener("click", async () => { const filters = readFilters(keywordInput, excludeKeywordInput, minLengthInput, maxLengthInput, afterTimeInput); if (filters.error) { setStatus(status, filters.error, "error"); return; } setBusy(applyButton, refreshButton, true); try { const records = await loadLibrary(false, status); const matches = filterMemes(records, filters); const stopped = stopActiveLoop(); applyResults(textarea, matches); const stoppedText = stopped ? "已停止当前发送;" : ""; const tiangouText = isTiangouModeEnabled() ? "请先关闭舔狗模式,再手动开始发送。" : "请手动开始发送。"; setStatus(status, `${stoppedText}已替换输入框:${matches.length} 条弹幕。${tiangouText}`, matches.length ? "success" : "warning"); } catch (error) { // loadLibrary 保留了文本区和显示下载错误 } finally { setBusy(applyButton, refreshButton, false); } }); refreshButton.addEventListener("click", async () => { setBusy(applyButton, refreshButton, true); try { await loadLibrary(true, status); } catch (error) { // loadLibrary 显示过下载错误了 } finally { setBusy(applyButton, refreshButton, false); } }); return true; } function mountWhenDouyuExIsReady() { const textarea = document.getElementById(TEXTAREA_ID); if (!textarea) return false; return insertFilterPanel(textarea); } if (mountWhenDouyuExIsReady()) return; const observer = new MutationObserver(() => { if (mountWhenDouyuExIsReady()) observer.disconnect(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); window.addEventListener("beforeunload", () => observer.disconnect(), { once: true }); })();