// ==UserScript== // @name GGN猜名词主持助手 // @namespace https://gazellegames.net/ // @version 1.1.1 // @description 专为 GGn threadid=960 设计的 Host 辅助工具:楼层复制问题按钮、Yes/No 编号列表累加器、PrivateBin v2 加密存证与 BBCode 生成 // @author feng9148 // @match https://gazellegames.net/forums.php*threadid=960* // @match https://www.gazellegames.net/forums.php*threadid=960* // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @connect privatebin.net // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('threadid') !== '960') return; // ==================== 状态持久化管理 ==================== const STORAGE_KEY = 'ggn_thread960_host_state_v1'; const PRIVATEBIN_SITE = 'https://privatebin.net/'; const PRIVATEBIN_EXPIRE = '3day'; const PRIVATEBIN_FORMATTER = 'plaintext'; const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const defaultState = { roundLocked: false, secretAnswer: '', pasteUrl: '', pastePassword: '', qaList: [], panelMinimized: false }; function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? Object.assign({}, defaultState, JSON.parse(raw)) : structuredClone(defaultState); } catch (e) { console.error('[Thread960] Failed to load state:', e); return structuredClone(defaultState); } } function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { console.error('[Thread960] Failed to save state:', e); } } let state = loadState(); function copyToClipboard(text) { try { GM_setClipboard(text); } catch (_) { navigator.clipboard?.writeText(text); } } // ==================== Base58 与密码学工具 (对齐猜数字助手 PrivateBin v2) ==================== function bytesToString(bytes) { let text = ''; for (let i = 0; i < bytes.length; i += 1) text += String.fromCharCode(bytes[i]); return text; } function stringToBytes(text) { return Uint8Array.from(text, (ch) => ch.charCodeAt(0)); } function base64Encode(bytes) { return btoa(bytesToString(bytes)); } function base64Decode(text) { const binary = atob(text); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); return bytes; } function base58Encode(bytes) { let value = 0n; for (let i = 0; i < bytes.length; i += 1) value = value * 256n + BigInt(bytes[i]); let encoded = ''; while (value > 0n) { encoded = BASE58_ALPHABET[Number(value % 58n)] + encoded; value /= 58n; } for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) encoded = '1' + encoded; return encoded; } function generateBase58Password(length = 10) { const range = BASE58_ALPHABET.length; const limit = Math.floor(0x100000000 / range) * range; const sample = new Uint32Array(1); const pick = () => { do { crypto.getRandomValues(sample); } while (sample[0] >= limit); return BASE58_ALPHABET[sample[0] % range]; }; for (;;) { let pwd = ''; for (let i = 0; i < length; i += 1) pwd += pick(); if (/[A-Z]/.test(pwd) && /[a-z]/.test(pwd) && /[0-9]/.test(pwd)) return pwd; } } async function deflateBytes(bytes) { if (typeof CompressionStream !== 'function' || typeof Blob !== 'function') return null; try { const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')); return new Uint8Array(await new Response(stream).arrayBuffer()); } catch (_) { return null; } } async function inflateBytes(bytes) { if (typeof DecompressionStream !== 'function' || typeof Blob !== 'function') return null; try { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); return new Uint8Array(await new Response(stream).arrayBuffer()); } catch (_) { return null; } } async function derivePasteKey(keyString, password, saltBytes, iterations, usages) { let keyBytes = stringToBytes(keyString); if (password.length > 0) { const passwordBytes = stringToBytes(password); const merged = new Uint8Array(keyBytes.length + passwordBytes.length); merged.set(keyBytes, 0); merged.set(passwordBytes, keyBytes.length); keyBytes = merged; } const imported = await crypto.subtle.importKey('raw', keyBytes, { name: 'PBKDF2' }, false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations, hash: { name: 'SHA-256' } }, imported, { name: 'AES-GCM', length: 256 }, false, usages ); } async function encryptPaste(message, password) { const randomBytes = (len) => { const bytes = new Uint8Array(len); crypto.getRandomValues(bytes); return bytes; }; const plaintext = new TextEncoder().encode(message); const compressed = await deflateBytes(plaintext); const iv = randomBytes(16); const salt = randomBytes(8); const iterations = 100000; const compression = compressed ? 'zlib' : 'none'; const adata = [ [base64Encode(iv), base64Encode(salt), iterations, 256, 128, 'aes', 'gcm', compression], PRIVATEBIN_FORMATTER, 0, 0 ]; const keyString = bytesToString(randomBytes(32)); const cipherKey = await derivePasteKey(keyString, password, salt, iterations, ['encrypt']); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(JSON.stringify(adata)), tagLength: 128 }, cipherKey, compressed || plaintext ); return { keyString, data: { v: 2, ct: base64Encode(new Uint8Array(encrypted)), adata, meta: { expire: PRIVATEBIN_EXPIRE } } }; } async function decipherPaste(keyString, password, ct, adata) { const spec = Array.isArray(adata[0]) ? adata[0] : adata; const iv = base64Decode(spec[0]); const salt = base64Decode(spec[1]); const cipherKey = await derivePasteKey(keyString, password, salt, spec[2], ['decrypt']); const plain = new Uint8Array(await crypto.subtle.decrypt( { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(JSON.stringify(adata)), tagLength: spec[4] }, cipherKey, base64Decode(ct) )); if (spec[7] === 'zlib') { const inflated = await inflateBytes(plain); if (inflated) return new TextDecoder().decode(inflated); } return new TextDecoder().decode(plain); } function gmFetch(method, url, body) { const headers = { 'X-Requested-With': 'JSONHttpRequest' }; let data = null; if (body !== undefined) { headers['Content-Type'] = 'application/json'; data = JSON.stringify(body); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers, data, timeout: 20000, onload: (res) => { if (res.status >= 200 && res.status < 300) { resolve(res.responseText); } else { reject(new Error(`HTTP ${res.status}`)); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')) }); }); } async function createPrivateBinPaste(secretText, password) { const message = JSON.stringify({ paste: String(secretText) }); const { keyString, data } = await encryptPaste(message, password); const postRes = await gmFetch('POST', PRIVATEBIN_SITE, data); const result = JSON.parse(postRes); if (!result || result.status !== 0 || !result.id) { throw new Error((result && result.message) || '服务拒绝了创建请求'); } // 拉取密文并解密自检 const checkRes = await gmFetch('GET', `${PRIVATEBIN_SITE}?${result.id}`); const fetched = JSON.parse(checkRes); const plain = await decipherPaste(keyString, password, fetched.ct, fetched.adata).catch(() => { throw new Error('自检失败:读回的密文无法用本局口令解开'); }); if (plain !== message) { throw new Error('自检失败:读回内容与上传内容不一致'); } return `${PRIVATEBIN_SITE}?${result.id}#${base58Encode(stringToBytes(keyString))}`; } // ==================== 楼层“复制问题”按钮注入 ==================== const POST_SELECTORS = 'table.forum_post, div.forum_post, table[id^="post"], div[id^="post"]'; function extractPostCleanQuestion(postEl) { const bodyEl = postEl.querySelector('div[id^="content"], .post_content, td.body, .body'); if (!bodyEl) return ''; const clone = bodyEl.cloneNode(true); clone.querySelectorAll('blockquote, .quote, .quoteheader, .last_edited, style, script').forEach(el => el.remove()); return clone.innerText.replace(/\s+/g, ' ').trim(); } function injectCopyButtons() { const candidates = document.querySelectorAll(POST_SELECTORS); candidates.forEach(postEl => { if (postEl.id === 'new_post_preview' || !/\d/.test(postEl.id || '')) return; if (postEl.parentElement && postEl.parentElement.closest(POST_SELECTORS)) return; if (postEl.getAttribute('data-qa-copy-injected') === 'true') return; const headerBar = postEl.querySelector('.colhead_dark, .post_header, tr.colhead_dark td, .post_head, thead tr td') || postEl; const wrapper = document.createElement('span'); wrapper.className = 'ggn-qa-copy-wrapper'; wrapper.style.cssText = 'display: inline-block; margin: 0 6px; vertical-align: middle;'; const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = '复制问题'; btn.style.cssText = 'font-size: 12px; font-family: sans-serif; padding: 2px 8px; height: auto; width: auto; cursor: pointer; line-height: 1.4; border-radius: 3px; border: 1px solid #666; background: #2b2b2b; color: #eee; box-sizing: border-box;'; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const questionText = extractPostCleanQuestion(postEl); if (!questionText) { btn.textContent = '无内容'; setTimeout(() => { btn.textContent = '复制问题'; }, 1200); return; } copyToClipboard(questionText); const inputEl = document.getElementById('ggn960-question-input'); if (inputEl) { inputEl.value = questionText; inputEl.focus(); } btn.textContent = '已复制!'; btn.style.background = '#2e6b34'; setTimeout(() => { btn.textContent = '复制问题'; btn.style.background = '#2b2b2b'; }, 1200); }); wrapper.appendChild(btn); let inserted = false; const links = Array.from(headerBar.querySelectorAll('a')); const postNumLink = links.find(a => /^#\d+$/.test(a.textContent.trim()) && postEl.contains(a)); if (postNumLink && postNumLink.parentNode) { postNumLink.parentNode.insertBefore(wrapper, postNumLink.nextSibling); inserted = true; } if (!inserted) { const userLink = links.find(a => /user\.php\?id=\d+/.test(a.getAttribute('href') || '') && postEl.contains(a)); if (userLink && userLink.parentNode) { userLink.parentNode.insertBefore(wrapper, userLink); inserted = true; } } if (!inserted && headerBar && headerBar !== postEl && postEl.contains(headerBar)) { headerBar.insertBefore(wrapper, headerBar.firstChild); inserted = true; } if (!inserted) { postEl.insertBefore(wrapper, postEl.firstChild); } postEl.setAttribute('data-qa-copy-injected', 'true'); }); } let scanScheduled = false; const observer = new MutationObserver(() => { if (!scanScheduled) { scanScheduled = true; queueMicrotask(() => { scanScheduled = false; injectCopyButtons(); }); } }); // ==================== 构建 Host 控制面板 UI ==================== function formatQAListText() { return state.qaList.map((item, idx) => `${idx + 1}. ${item.question} -${item.answer}`).join('\n'); } function generateStartBBCode() { const url = state.pasteUrl || '[Paste URL 未设置]'; return `[align=center][size=7][b][url=${url}]New Round[/url][/b][/size]\n[/align]`; } function generateWinBBCode() { const url = state.pasteUrl || '[Paste URL 未设置]'; const ans = state.secretAnswer || '[未设置]'; const pwd = state.pastePassword || '[未设置]'; return `[align=center][size=6][b]Win[/b][/size]\n[b]Secret Answer:[/b] ${ans}\n[b]Password:[/b] [code]${pwd}[/code]\n[url=${url}]Password Protected Paste[/url][/align]`; } function appendToQuickPost(text) { const textarea = document.querySelector('#quickpost, textarea[name="body"]'); if (!textarea) { alert('未找到回帖输入框 (#quickpost)'); return false; } const prefix = textarea.value.trim().length > 0 ? textarea.value.replace(/\s+$/, '') + '\n\n' : ''; textarea.value = prefix + text; textarea.focus(); textarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); return true; } function injectStyles() { const style = document.createElement('style'); style.textContent = ` #ggn960-host-panel, #ggn960-host-panel * { box-sizing: border-box !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif; } #ggn960-host-panel { position: fixed; bottom: 20px; right: 20px; width: 380px; max-width: calc(100vw - 24px); background: #1c1f26; color: #e2e8f0; border: 1px solid #3b4252; border-radius: 8px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.75); font-size: 13px; line-height: 1.4; z-index: 99999; overflow: hidden; } #ggn960-header { background: #2e3440; padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; border-bottom: 1px solid #3b4252; user-select: none; } #ggn960-body { padding: 12px 14px; max-height: 82vh; overflow-y: auto; overflow-x: hidden; } .ggn960-section { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #2e3440; } .ggn960-section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .ggn960-title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; gap: 6px; flex-wrap: wrap; } .ggn960-input, .ggn960-textarea { width: 100% !important; min-width: 0 !important; max-width: 100% !important; padding: 6px 8px !important; margin: 0 !important; background: #242933 !important; border: 1px solid #4c566a !important; color: #eceff4 !important; border-radius: 4px !important; font-size: 12px !important; line-height: 1.4 !important; height: auto !important; outline: none !important; transition: border-color 0.15s, background 0.15s; } .ggn960-input:focus, .ggn960-textarea:focus { border-color: #88c0d0 !important; } .ggn960-input.ggn960-copyable { cursor: pointer !important; } .ggn960-input.ggn960-copyable:hover { border-color: #88c0d0 !important; background: #2b313d !important; } .ggn960-input[readonly] { background: #191d24 !important; color: #a3be8c !important; border-color: #3b4252 !important; } .ggn960-btn-row { display: flex; gap: 6px; width: 100%; } .ggn960-btn { flex: 1 1 0%; min-width: 0 !important; height: auto !important; padding: 6px 8px !important; margin: 0 !important; border-radius: 4px !important; font-size: 12px !important; line-height: 1.3 !important; font-weight: 600 !important; cursor: pointer !important; text-align: center !important; white-space: normal !important; word-break: break-word !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; transition: filter 0.15s, opacity 0.15s; } .ggn960-btn:hover:not(:disabled) { filter: brightness(1.12); } .ggn960-btn:disabled { opacity: 0.45 !important; cursor: not-allowed !important; } .ggn960-btn-sm { flex: 0 0 auto !important; padding: 3px 8px !important; font-size: 11px !important; font-weight: normal !important; } .ggn960-field-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; } .ggn960-field-label { font-size: 11px; color: #9aa5b4; display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; } .ggn960-click-hint { font-size: 10px; color: #81a1c1; opacity: 0.85; } `; document.head.appendChild(style); } function buildUI() { injectStyles(); const panel = document.createElement('div'); panel.id = 'ggn960-host-panel'; panel.innerHTML = `
🎯 GGN猜名词主持助手
1. 回答问题 (点楼层“复制问题”自动填入)
2. 累计问答列表 (0)
3. PrivateBin 存证 (3天) & 回合管理
本局秘密答案 (Secret Answer) 👆 点击框体即可复制
PrivateBin 链接 👆 点击框体即可复制
10位 Base58 口令 👆 点击框体即可复制
`; document.body.appendChild(panel); const elHeader = document.getElementById('ggn960-header'); const elToggleBtn = document.getElementById('ggn960-toggle-btn'); const elBody = document.getElementById('ggn960-body'); const elQuestionInput = document.getElementById('ggn960-question-input'); const elBtnYes = document.getElementById('ggn960-btn-yes'); const elBtnNo = document.getElementById('ggn960-btn-no'); const elQaCount = document.getElementById('ggn960-qa-count'); const elQaOutput = document.getElementById('ggn960-qa-output'); const elBtnUndo = document.getElementById('ggn960-btn-undo'); const elBtnClearQa = document.getElementById('ggn960-btn-clear-qa'); const elBtnCopyQa = document.getElementById('ggn960-btn-copy-qa'); const elBtnInsertQa = document.getElementById('ggn960-btn-insert-qa'); const elSecretInput = document.getElementById('ggn960-secret-input'); const elPasteUrl = document.getElementById('ggn960-paste-url'); const elPastePwd = document.getElementById('ggn960-paste-pwd'); const elLockBadge = document.getElementById('ggn960-lock-badge'); const elStatusMsg = document.getElementById('ggn960-status-msg'); const elBtnAutoPb = document.getElementById('ggn960-btn-auto-pb'); const elBtnManualLock = document.getElementById('ggn960-btn-manual-lock'); const elBtnResetRound = document.getElementById('ggn960-btn-reset-round'); const elBtnBBCodeStart = document.getElementById('ggn960-btn-bbcode-start'); const elBtnBBCodeWin = document.getElementById('ggn960-btn-bbcode-win'); function setStatus(msg, isError = false) { elStatusMsg.textContent = msg; elStatusMsg.style.color = isError ? '#bf616a' : '#88c0d0'; } function renderStateToUI() { elQaCount.textContent = state.qaList.length; elQaOutput.value = formatQAListText(); elSecretInput.value = state.secretAnswer; elPasteUrl.value = state.pasteUrl; elPastePwd.value = state.pastePassword; const locked = state.roundLocked; elSecretInput.readOnly = locked; elPasteUrl.readOnly = locked; elPastePwd.readOnly = locked; elBtnAutoPb.disabled = locked; elBtnManualLock.disabled = locked; if (locked) { elLockBadge.textContent = '🔒 已锁定 (点击内容可复制)'; elLockBadge.style.background = '#2e5936'; elLockBadge.style.color = '#a3be8c'; } else { elLockBadge.textContent = '🔓 未锁定'; elLockBadge.style.background = '#3b4252'; elLockBadge.style.color = '#d8dee9'; } } function bindClickToCopy(inputEl, labelName) { inputEl.addEventListener('click', () => { const val = inputEl.value.trim(); if (!val) return; copyToClipboard(val); if (state.roundLocked) { inputEl.select(); } setStatus(`📋 已复制【${labelName}】到剪贴板`); }); } bindClickToCopy(elSecretInput, '秘密答案'); bindClickToCopy(elPasteUrl, 'PrivateBin 链接'); bindClickToCopy(elPastePwd, '口令'); elHeader.addEventListener('click', () => { state.panelMinimized = !state.panelMinimized; elBody.style.display = state.panelMinimized ? 'none' : 'block'; elToggleBtn.textContent = state.panelMinimized ? '🔼' : '🔽'; saveState(); }); function recordAnswer(ansType) { const q = elQuestionInput.value.trim(); if (!q) { setStatus('请先复制或输入一个问题!', true); elQuestionInput.focus(); return; } state.qaList.push({ question: q, answer: ansType }); elQuestionInput.value = ''; saveState(); renderStateToUI(); elQaOutput.scrollTop = elQaOutput.scrollHeight; setStatus(`已添加第 ${state.qaList.length} 题 (-${ansType})`); } elBtnYes.addEventListener('click', () => recordAnswer('Yes')); elBtnNo.addEventListener('click', () => recordAnswer('No')); elQaOutput.addEventListener('change', () => { const lines = elQaOutput.value.split('\n').map(l => l.trim()).filter(Boolean); const parsed = []; lines.forEach(line => { const match = line.match(/^(?:\d+\.\s*)?(.+?)\s*-(Yes|No.*)$/i); if (match) { parsed.push({ question: match[1].trim(), answer: match[2].trim() }); } else { parsed.push({ question: line.replace(/^\d+\.\s*/, ''), answer: 'Yes' }); } }); state.qaList = parsed; saveState(); renderStateToUI(); }); elBtnUndo.addEventListener('click', () => { if (state.qaList.length === 0) return; state.qaList.pop(); saveState(); renderStateToUI(); }); elBtnClearQa.addEventListener('click', () => { if (!confirm('确定要清空当前所有问答记录吗?')) return; state.qaList = []; saveState(); renderStateToUI(); }); elBtnCopyQa.addEventListener('click', () => { const text = elQaOutput.value.trim(); if (!text) return setStatus('问答列表为空!', true); copyToClipboard(text); setStatus('✅ 问答列表已复制到剪贴板'); }); elBtnInsertQa.addEventListener('click', () => { const text = elQaOutput.value.trim(); if (!text) return setStatus('问答列表为空!', true); if (appendToQuickPost(text)) { setStatus('✅ 已填入底部回帖框'); } }); [elSecretInput, elPasteUrl, elPastePwd].forEach(inp => { inp.addEventListener('input', () => { if (state.roundLocked) return; state.secretAnswer = elSecretInput.value.trim(); state.pasteUrl = elPasteUrl.value.trim(); state.pastePassword = elPastePwd.value.trim(); saveState(); }); }); elBtnAutoPb.addEventListener('click', async () => { if (state.roundLocked) return; const secret = elSecretInput.value.trim(); if (!secret) { setStatus('请先填写本局秘密答案!', true); elSecretInput.focus(); return; } try { elBtnAutoPb.disabled = true; setStatus('⏳ 正在加密上传至 privatebin.net (有效期3天)...'); const pwd = elPastePwd.value.trim() || generateBase58Password(10); const shareUrl = await createPrivateBinPaste(secret, pwd); state.secretAnswer = secret; state.pastePassword = pwd; state.pasteUrl = shareUrl; state.roundLocked = true; saveState(); renderStateToUI(); copyToClipboard(shareUrl); setStatus('✅ 3天存证上传并自检成功!链接已自动复制。'); } catch (err) { console.error('[PrivateBin Error]', err); elBtnAutoPb.disabled = false; setStatus('❌ ' + (err.message || '上传或自检失败'), true); } }); elBtnManualLock.addEventListener('click', () => { if (state.roundLocked) return; const url = elPasteUrl.value.trim(); if (!url) { setStatus('手动锁定需要至少填入 Paste 链接!', true); return; } state.secretAnswer = elSecretInput.value.trim(); state.pasteUrl = url; state.pastePassword = elPastePwd.value.trim() || generateBase58Password(10); state.roundLocked = true; saveState(); renderStateToUI(); setStatus('✅ 手动绑定并锁定成功!点击框体即可复制对应内容。'); }); elBtnResetRound.addEventListener('click', () => { if (!confirm('确定要解锁并重置本局存证与问答列表吗?(开启新回合时使用)')) return; state.roundLocked = false; state.secretAnswer = ''; state.pasteUrl = ''; state.pastePassword = ''; state.qaList = []; saveState(); renderStateToUI(); setStatus('已重置,可开始新回合配置。'); }); elBtnBBCodeStart.addEventListener('click', () => { const bb = generateStartBBCode(); copyToClipboard(bb); appendToQuickPost(bb); setStatus('✅ 开局帖 BBCode 已复制并填入回帖框'); }); elBtnBBCodeWin.addEventListener('click', () => { const bb = generateWinBBCode(); copyToClipboard(bb); appendToQuickPost(bb); setStatus('🏆 结束帖 BBCode 已复制并填入回帖框'); }); renderStateToUI(); } // ==================== 启动入口 ==================== injectCopyButtons(); buildUI(); observer.observe(document.body, { childList: true, subtree: true }); })();