// ==UserScript== // @name B站投币检测器:我要为ta投币! // @namespace https://space.bilibili.com/496259679 // @version 1.1.0 // @description 该脚本可以在任一B站UP主的所有投稿页面 (https://space.bilibili.com/*/upload/video) 检测并显示投币数量。由DeepSeek协助编写,经多次测试修复明显问题。作者只是想要给一个喜欢的up主的所有视频都投币,但是总有遗漏的,于是有了这个脚本...欢迎批评指正! // @author 凌绝顶览山小 // @tag 哔哩哔哩 bilibili 投币 检测 // @license MIT // @match https://space.bilibili.com/* // @grant GM_addStyle // @run-at document-end // ==/UserScript== (function() { 'use strict'; // ========== 全局状态 ========== let autoDetectEnabled = false; let isDetecting = false; let abortDetection = false; let detectTimeouts = []; let pageCheckInterval = null; let lastPage = null; let pendingPage = null; let stabilizationTimer = null; let pageChangeCount = 0; let widgetBuilt = false; // 标记是否已构建widget // ========== UI 样式 ========== GM_addStyle(` #coin-detector-widget { position: fixed; bottom: 120px; right: 20px; z-index: 999999; font-family: -apple-system, "Helvetica Neue", sans-serif; user-select: none; } #coin-detector-toggle { width: 48px; height: 48px; border-radius: 50%; background: #fb7299; color: #fff; font-size: 20px; font-weight: bold; text-align: center; line-height: 48px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3); transition: transform 0.2s; border: none; outline: none; } #coin-detector-toggle:hover { transform: scale(1.05); } #coin-detector-panel { display: none; position: absolute; bottom: 60px; right: 0; width: 120px; background: rgba(30, 30, 40, 0.6); border-radius: 12px; padding: 12px 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); color: #eee; font-size: 12px; border: 1px solid rgba(255,255,255,0.1); } #coin-detector-panel.open { display: block; } #coin-detector-panel .panel-title { font-weight: bold; margin-bottom: 12px; text-align: center; color: #fff; border-bottom: 1px solid #444; padding-bottom: 8px; } #coin-detector-panel .control-group { display: flex; flex-direction: column; gap: 12px; } #coin-detector-panel .btn { padding: 8px 12px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 0.2s; font-weight: bold; } #coin-detector-panel .btn-primary { background: #00a1d6; color: #fff; } #coin-detector-panel .btn-primary:hover { background: #0088b0; } #coin-detector-panel .btn-danger { background: #fb7299; color: #fff; } #coin-detector-panel .btn-danger:hover { background: #e05a7e; } #coin-detector-panel .toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; } #coin-detector-panel .toggle-label { color: #ccc; } .switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #555; transition: .3s; border-radius: 24px; } .slider::before { content: ""; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; } .switch input:checked + .slider { background-color: #00a1d6; } .switch input:checked + .slider::before { transform: translateX(20px); } #detect-status { margin-top: 10px; font-size: 12px; color: #aaa; text-align: center; min-height: 18px; border-top: 1px solid #444; padding-top: 8px; } .custom-coin-badge { position: absolute; top: 8px; right: 8px; padding: 2px 12px; border-radius: 30px; color: #fff; font-size: 13px; font-weight: bold; z-index: 9999; pointer-events: none; box-shadow: 0 2px 8px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); } /* 投币按钮容器 */ .custom-coin-btn-container { position: absolute; bottom: 8px; right: 8px; display: flex; gap: 6px; z-index: 9998; } .custom-coin-btn { padding: 2px 8px; font-size: 12px; border: none; border-radius: 12px; color: #fff; background: #fb7299; cursor: pointer; transition: background 0.2s; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.3); } .custom-coin-btn:hover { background: #e05a7e; } .custom-coin-btn:disabled { opacity: 0.5; cursor: not-allowed; } `); // ========== 工具函数 ========== function updateStatus(msg) { const statusEl = document.getElementById('detect-status'); if (statusEl) statusEl.textContent = msg; } function getCurrentPage() { const activeBtn = document.querySelector('.vui_pagenation--btn.vui_button--active'); if (activeBtn) { const text = activeBtn.textContent.trim(); const num = parseInt(text); if (!isNaN(num) && num > 0) return num; } const params = new URLSearchParams(location.search); const p = parseInt(params.get('p')); return !isNaN(p) && p > 0 ? p : 1; } function getCurrentCards() { return document.querySelectorAll('div.upload-video-card'); } function clearDetectTimeouts() { detectTimeouts.forEach(id => clearTimeout(id)); detectTimeouts = []; } function resetCardDetectFlags() { document.querySelectorAll('div.upload-video-card').forEach(card => { delete card.dataset.coinDetected; }); } function setDetectButtonState(detecting) { const btn = document.getElementById('detect-now-btn'); if (!btn) return; if (detecting) { btn.textContent = '⏹ 停止检测'; btn.className = 'btn btn-danger'; } else { btn.textContent = '⚡ 立刻检测'; btn.className = 'btn btn-primary'; } } // ========== 核心检测 ========== function detectVideos(cardList, forceReset = false) { if (isDetecting) return; if (forceReset) { document.querySelectorAll('.custom-coin-badge, .custom-coin-btn-container').forEach(el => el.remove()); resetCardDetectFlags(); } const cardsToDetect = []; cardList.forEach(card => { if (!card.dataset.coinDetected) { const link = card.querySelector('a[href*="/video/BV"]'); if (link) { const match = link.href.match(/\/video\/(BV\w+)/); if (match) { cardsToDetect.push({ bvid: match[1], card }); } } } }); if (cardsToDetect.length === 0) { updateStatus('当前页无未检测视频'); return; } isDetecting = true; abortDetection = false; setDetectButtonState(true); const total = cardsToDetect.length; let processed = 0; let errorCount = 0; updateStatus(`检测中 0/${total}`); const timeouts = []; cardsToDetect.forEach((item, index) => { const timeoutId = setTimeout(() => { if (abortDetection) return; const { bvid, card } = item; // 先获取视频信息(aid, copyright) fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, { credentials: 'include' }) .then(res => res.json()) .then(viewData => { if (abortDetection) return; if (viewData.code !== 0) throw new Error('获取视频信息失败'); const aid = viewData.data.aid; const copyright = viewData.data.copyright; const maxCoins = (copyright === 1) ? 2 : 1; // 存储aid和maxCoins到卡片dataset card.dataset.aid = aid; card.dataset.maxCoins = maxCoins; card.dataset.bvid = bvid; // 获取投币数量 return getCoinCount(bvid).then(coinCount => { if (abortDetection) return; processed++; // 构建角标和按钮 buildBadgeAndButtons(card, maxCoins, coinCount); card.dataset.coinDetected = 'true'; updateStatus(`检测中 ${processed}/${total}`); }); }) .catch(err => { if (abortDetection) return; processed++; errorCount++; updateStatus(`检测中 ${processed}/${total} (部分失败)`); console.warn(`检测失败 [${bvid}]:`, err); }) .finally(() => { if (processed === total) { if (abortDetection) return; isDetecting = false; setDetectButtonState(false); if (errorCount > 0) { updateStatus(`完成 (${errorCount}个失败)`); } else { updateStatus(`完成,共检测 ${total} 个视频`); } } }); }, index * 500); timeouts.push(timeoutId); }); detectTimeouts = timeouts; } function stopDetection() { if (isDetecting) { abortDetection = true; clearDetectTimeouts(); isDetecting = false; setDetectButtonState(false); updateStatus('检测已停止'); } } // ========== 投币功能 ========== // 获取 CSRF Token function getCSRF() { const match = document.cookie.match(/bili_jct=([^;]+)/); return match ? match[1] : ''; } // 投币 API function addCoin(aid, multiply, csrf) { return fetch('https://api.bilibili.com/x/web-interface/coin/add', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `aid=${aid}&multiply=${multiply}&csrf=${csrf}`, credentials: 'include' }).then(res => res.json()); } // 查询单个视频的投币数量 function getCoinCount(bvid) { return fetch(`https://api.bilibili.com/x/web-interface/archive/coins?bvid=${bvid}`, { credentials: 'include' }) .then(res => res.json()) .then(data => { let coinCount = 0; if (data.code === 0 && data.data) { if (typeof data.data === 'number') { coinCount = data.data; } else if (typeof data.data === 'object') { coinCount = data.data.multiply || data.data.total || data.data.coin || 0; } } return coinCount; }); } // 更新单个卡片(重新查询并刷新角标和按钮) function updateSingleCard(card) { const bvid = card.dataset.bvid; if (!bvid) return; // 先获取aid和copyright fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, { credentials: 'include' }) .then(res => res.json()) .then(viewData => { if (viewData.code !== 0) throw new Error('获取视频信息失败'); const aid = viewData.data.aid; const copyright = viewData.data.copyright; // 1:自制, 2:转载 const maxCoins = (copyright === 1) ? 2 : 1; // 获取投币数 return getCoinCount(bvid).then(coinCount => { // 更新卡片上的data属性 card.dataset.aid = aid; card.dataset.maxCoins = maxCoins; // 重建角标和按钮 buildBadgeAndButtons(card, maxCoins, coinCount); }); }) .catch(err => { console.warn('更新单卡失败:', err); updateStatus('更新视频状态失败'); }); } // 构建角标和按钮(由detectVideos和updateSingleCard调用) function buildBadgeAndButtons(card, maxCoins, coinCount) { // 移除旧的角标和按钮容器 const oldBadge = card.querySelector('.custom-coin-badge'); if (oldBadge) oldBadge.remove(); const oldContainer = card.querySelector('.custom-coin-btn-container'); if (oldContainer) oldContainer.remove(); // 创建角标 const badge = document.createElement('div'); badge.className = 'custom-coin-badge'; const isCoined = coinCount > 0; badge.style.backgroundColor = isCoined ? '#00a1d6' : '#fb7299'; badge.textContent = isCoined ? `已投${coinCount}币` : '未投'; if (getComputedStyle(card).position === 'static') { card.style.position = 'relative'; } card.appendChild(badge); // 判断是否可投(未投满) const remaining = maxCoins - coinCount; if (remaining <= 0) return; // 已满,不显示按钮 // 创建按钮容器 const container = document.createElement('div'); container.className = 'custom-coin-btn-container'; // 生成按钮:如果剩余2个,则显示“投1”和“投2”;如果剩余1个,显示“投1” const buttonValues = (remaining === 2) ? [1, 2] : [1]; buttonValues.forEach(val => { const btn = document.createElement('button'); btn.className = 'custom-coin-btn'; btn.textContent = `投${val}币`; btn.dataset.multiply = val; btn.addEventListener('click', (e) => { e.stopPropagation(); if (btn.disabled) return; btn.disabled = true; btn.textContent = '处理中...'; const aid = card.dataset.aid; const csrf = getCSRF(); if (!csrf) { alert('未获取到CSRF,请重新登录B站'); btn.disabled = false; btn.textContent = `投${val}币`; return; } addCoin(aid, val, csrf) .then(res => { if (res.code === 0) { // 投币成功,等待1.5秒后更新状态 updateStatus('投币成功,正在刷新...'); setTimeout(() => { updateSingleCard(card); }, 1500); } else { alert(`投币失败:${res.message || '未知错误'}`); btn.disabled = false; btn.textContent = `投${val}币`; } }) .catch(err => { alert('投币请求失败,请检查网络'); btn.disabled = false; btn.textContent = `投${val}币`; }); }); container.appendChild(btn); }); card.appendChild(container); } // ========== 翻页处理(带稳定等待) ========== function handlePageChange(newPage) { if (stabilizationTimer) { clearTimeout(stabilizationTimer); stabilizationTimer = null; } pendingPage = newPage; pageChangeCount++; if (isDetecting) { stopDetection(); } resetCardDetectFlags(); document.querySelectorAll('.custom-coin-btn-container').forEach(el => el.remove()); updateStatus(`已翻页至第${newPage}页,等待页面稳定...`); let retryCount = 0; const maxRetries = 15; function checkStability() { const cards = getCurrentCards(); if (cards.length > 0 && Array.from(cards).some(c => c.querySelector('a[href*="/video/BV"]'))) { setTimeout(() => { const cards2 = getCurrentCards(); if (cards2.length === cards.length) { if (autoDetectEnabled) { document.querySelectorAll('.custom-coin-badge').forEach(el => el.remove()); resetCardDetectFlags(); detectVideos(cards2, false); updateStatus(`开始检测第${newPage}页`); } else { updateStatus(`第${newPage}页已稳定,但自动检测未开启`); } stabilizationTimer = null; pendingPage = null; return; } stabilizationTimer = setTimeout(checkStability, 500); }, 800); } else { retryCount++; if (retryCount > maxRetries) { updateStatus(`等待超时,请手动刷新页面`); stabilizationTimer = null; pendingPage = null; return; } stabilizationTimer = setTimeout(checkStability, 500); } } stabilizationTimer = setTimeout(checkStability, 500); } function checkPageChange() { const currentPage = getCurrentPage(); if (lastPage === null) { lastPage = currentPage; return; } if (currentPage !== lastPage) { lastPage = currentPage; handlePageChange(currentPage); } } // ========== 自动检测控制 ========== function startAutoDetect() { if (pageCheckInterval) clearInterval(pageCheckInterval); pageCheckInterval = setInterval(checkPageChange, 1000); lastPage = getCurrentPage(); const cards = getCurrentCards(); detectVideos(cards, false); // 不强制清除 } function stopAutoDetect() { if (pageCheckInterval) { clearInterval(pageCheckInterval); pageCheckInterval = null; } if (stabilizationTimer) { clearTimeout(stabilizationTimer); stabilizationTimer = null; } if (isDetecting) { stopDetection(); } updateStatus('自动检测已关闭'); } // ========== 构建悬浮窗 ========== function buildWidget() { if (document.getElementById('coin-detector-widget')) return; // 已存在则跳过 widgetBuilt = true; const widget = document.createElement('div'); widget.id = 'coin-detector-widget'; const toggleBtn = document.createElement('div'); toggleBtn.id = 'coin-detector-toggle'; toggleBtn.textContent = '币'; toggleBtn.title = '点击展开/收起投币检测'; widget.appendChild(toggleBtn); const panel = document.createElement('div'); panel.id = 'coin-detector-panel'; panel.innerHTML = `
💰 投币检测
🔄 自动
就绪
`; widget.appendChild(panel); document.body.appendChild(widget); // 事件绑定 toggleBtn.addEventListener('click', () => { panel.classList.toggle('open'); }); const detectBtn = document.getElementById('detect-now-btn'); detectBtn.addEventListener('click', () => { if (isDetecting) { stopDetection(); return; } const cards = getCurrentCards(); if (cards.length === 0) { updateStatus('未找到视频卡片'); return; } document.querySelectorAll('.custom-coin-badge').forEach(el => el.remove()); resetCardDetectFlags(); lastPage = getCurrentPage(); detectVideos(cards, false); }); const autoToggle = document.getElementById('auto-detect-toggle'); autoToggle.addEventListener('change', (e) => { autoDetectEnabled = e.target.checked; if (autoDetectEnabled) { startAutoDetect(); } else { stopAutoDetect(); } }); lastPage = getCurrentPage(); updateStatus('插件已加载'); } // ========== 初始化与路由监听 ========== function ensureWidget() { // 只在投稿页且存在视频卡片时构建,且尚未构建 if (!document.getElementById('coin-detector-widget') && location.pathname.includes('/upload/video') && document.querySelector('div.upload-video-card')) { buildWidget(); } } // 初次加载尝试 if (document.readyState === 'complete') { ensureWidget(); } else { window.addEventListener('load', ensureWidget); } // 监听路由变化(定时器检测URL变化) let lastUrl = location.href; setInterval(() => { if (location.href !== lastUrl) { lastUrl = location.href; // 如果URL变化,尝试构建widget(若尚未构建) ensureWidget(); } }, 1000); // 额外监听DOM变化,当视频卡片出现时尝试构建 const domObserver = new MutationObserver(() => { if (!document.getElementById('coin-detector-widget') && location.pathname.includes('/upload/video') && document.querySelector('div.upload-video-card')) { buildWidget(); } }); domObserver.observe(document.body, { childList: true, subtree: true }); })();