// ==UserScript== // @name 云朵商城助手 // @namespace https://shop.cloudbread.cn/ // @version 2.4.4 // @author 脆脆的猫猫鲨 // @description 在B站直播间快速使用云朵商城服务(歌单 + 商城 + 礼物数据上传),数据通过开放 API 实时获取 // @copyright 脆脆的猫猫鲨 // @icon https://shop.cloudbread.cn/favicon.png // @homepage https://shop.cloudbread.cn/ // @supportURL https://shop.cloudbread.cn/ // @match https://live.bilibili.com/* // @connect shop.cloudbread.cn // @connect api.live.bilibili.com // @connect api.vc.bilibili.com // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; const VERSION = '2.4.4'; const API_BASE = 'https://shop.cloudbread.cn/api/v1/open'; const SITE_BASE = 'https://shop.cloudbread.cn'; const ICON_URL = 'https://shop.cloudbread.cn/favicon.png'; let enabled = GM_getValue('enabled', true); // ============ 日志工具 ============ const log = { info: (...e) => console.log('%c 云朵商城助手 %c 信息 %c ' + e.join(''), 'background:#F59E0B;color:#fff;padding:2px 2px;border-radius:6px 0 0 6px;', 'background:#2196f3;color:#fff;padding:2px 2px;border-radius:0 6px 6px 0;', 'color:#000;padding:2px 2px;'), success: (...e) => console.log('%c 云朵商城助手 %c 成功 %c ' + e.join(''), 'background:#F59E0B;color:#fff;padding:2px 2px;border-radius:6px 0 0 6px;', 'background:#4caf50;color:#fff;padding:2px 2px;border-radius:0 6px 6px 0;', 'color:#000;padding:2px 2px;'), warn: (...e) => console.warn('%c 云朵商城助手 %c 警告 %c ' + e.join(''), 'background:#F59E0B;color:#fff;padding:2px 2px;border-radius:6px 0 0 6px;', 'background:#fb8c00;color:#fff;padding:2px 2px;border-radius:0 6px 6px 0;', 'color:#000;padding:2px 2px;'), error: (...e) => console.error('%c 云朵商城助手 %c 错误 %c ' + e.join(''), 'background:#F59E0B;color:#fff;padding:2px 2px;border-radius:6px 0 0 6px;', 'background:#b00020;color:#fff;padding:2px 2px;border-radius:0 6px 6px 0;', 'color:#000;padding:2px 2px;') }; // ============ 样式注入 ============ const PANEL_CSS = ` .cb-panel{position:absolute;left:0;top:0;width:100%;height:100%;background:#fff;z-index:800;display:none;flex-direction:column;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif} .cb-panel.active{display:flex} .cb-panel-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:linear-gradient(135deg,#F59E0B,#FBBF24);color:#fff;flex-shrink:0} .cb-panel-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px} .cb-panel-close{cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:50%;transition:background .2s} .cb-panel-close:hover{background:rgba(255,255,255,.25)} .cb-panel-tabs{display:flex;background:#f5f5f5;border-bottom:1px solid #e0e0e0;flex-shrink:0} .cb-tab{flex:1;padding:8px 12px;text-align:center;font-size:13px;cursor:pointer;color:#666;border-bottom:2px solid transparent;transition:all .2s} .cb-tab.active{color:#F59E0B;border-bottom-color:#F59E0B;background:#fff;font-weight:600} .cb-tab:hover:not(.active){color:#F59E0B} .cb-tab .cb-tab-badge{display:inline-block;background:#eee;color:#888;border-radius:10px;padding:1px 6px;font-size:10px;margin-left:4px} .cb-tab.active .cb-tab-badge{background:#FFF3CD;color:#F59E0B} .cb-panel-fixed-header{flex-shrink:0;background:#fff;z-index:10} .cb-panel-body{flex:1;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch} .cb-panel-body::-webkit-scrollbar{width:4px} .cb-panel-body::-webkit-scrollbar-thumb{background:#ddd;border-radius:2px} .cb-search-bar-wrap{display:flex;flex-direction:column;gap:6px;padding:8px 12px;background:#fafafa;border-bottom:1px solid #e0e0e0;flex-shrink:0} .cb-search-bar{display:flex;gap:6px} .cb-search-input{flex:1;border:1px solid #ddd;border-radius:6px;padding:4px 8px;font-size:12px;outline:none} .cb-search-input:focus{border-color:#F59E0B} .cb-search-btn{background:#F59E0B;color:#fff;border:none;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer} .cb-search-btn:hover{background:#D97706} .cb-random-btn{background:linear-gradient(135deg,#8B5CF6,#EC4899);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;transition:all .2s;align-self:flex-start} .cb-random-btn:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(139,92,246,.4)} .cb-random-btn:disabled{opacity:.6;cursor:not-allowed;transform:none;box-shadow:none} .cb-tag-bar{display:flex;padding:6px 12px;background:#fafafa;border-bottom:1px solid #e0e0e0;gap:6px;overflow-x:auto;flex-shrink:0;flex:1;min-width:0} .cb-tag-bar::-webkit-scrollbar{display:none} .cb-tag-bar-wrap{display:flex;align-items:center;background:#fafafa;border-bottom:1px solid #e0e0e0;flex-shrink:0} .cb-tag-bar-wrap .cb-tag-bar{border-bottom:none;padding:6px 8px} .cb-tag-scroll-btn{width:24px;height:24px;flex-shrink:0;border:none;background:transparent;color:#999;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:50%;transition:all .2s;padding:0;line-height:1} .cb-tag-scroll-btn:hover{background:#eee;color:#F59E0B} .cb-tag-scroll-btn:active{background:#ddd} .cb-tag-scroll-left{margin-left:4px} .cb-tag-scroll-right{margin-right:4px} .cb-tag-bar-wrap:not(.has-overflow) .cb-tag-scroll-btn{display:none} .cb-tag-item{cursor:pointer;white-space:nowrap;padding:3px 10px;border-radius:12px;font-size:11px;background:#eee;color:#666;border:1px solid transparent;transition:all .2s} .cb-tag-item.active{background:#F59E0B;color:#fff} .cb-tag-item:hover:not(.active){border-color:#F59E0B;color:#F59E0B} .cb-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px;color:#999} .cb-loading-spinner{width:28px;height:28px;border:3px solid #eee;border-top-color:#F59E0B;border-radius:50%;animation:cb-spin .8s linear infinite} @keyframes cb-spin{to{transform:rotate(360deg)}} .cb-list-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding:16px 12px;color:#999;font-size:12px} .cb-list-loading-spinner{width:16px;height:16px;border:2px solid #eee;border-top-color:#F59E0B;border-radius:50%;animation:cb-spin .8s linear infinite} .cb-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px;color:#999;text-align:center} .cb-empty-icon{font-size:36px;margin-bottom:8px;opacity:.5} .cb-empty-text{font-size:13px;line-height:1.6} .cb-song-item{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background .15s} .cb-song-item:hover{background:#FFF8E1} .cb-song-cover{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0;background:#eee} .cb-song-info{flex:1;min-width:0;margin-left:8px} .cb-song-title{font-size:13px;color:#333;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cb-song-artist{font-size:11px;color:#999;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cb-song-tags{display:flex;gap:4px;margin-top:3px;flex-wrap:wrap} .cb-song-tag{font-size:9px;padding:1px 5px;border-radius:3px;background:#eee;color:#666} .cb-song-meta{display:flex;align-items:center;gap:6px;margin-top:2px} .cb-song-prof{font-size:10px;padding:1px 6px;border-radius:8px} .cb-song-play{font-size:10px;color:#aaa} .cb-goods-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:8px 12px} .cb-goods-card{border-radius:8px;background:#fff;border:1px solid #f0f0f0;overflow:hidden;cursor:pointer;transition:all .2s} .cb-goods-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.1);transform:translateY(-1px)} .cb-goods-card.sold-out{opacity:.7} .cb-goods-card-img-wrap{position:relative;width:100%;padding-top:100%;background:#f5f5f5;overflow:hidden} .cb-goods-card-img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover} .cb-goods-sold-out-mask{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:500} .cb-goods-card-info{padding:8px 10px} .cb-goods-card-title{font-size:12px;color:#333;font-weight:500;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4;min-height:34px} .cb-goods-card-desc{font-size:10px;color:#999;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cb-goods-card-bottom{display:flex;align-items:flex-end;justify-content:space-between;margin-top:6px} .cb-goods-card-price{display:flex;align-items:baseline;color:#F59E0B} .cb-goods-price-symbol{font-size:10px;font-weight:500} .cb-goods-price-num{font-size:16px;font-weight:700;margin-left:1px} .cb-goods-price-unit{font-size:10px;color:#999;margin-left:2px;font-weight:400} .cb-goods-card-sales{font-size:10px;color:#999} .cb-invite{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:30px 20px;text-align:center;height:100%} .cb-invite-icon{width:96px;height:96px;object-fit:contain;margin-bottom:12px;border-radius:16px;box-shadow:0 4px 16px rgba(0,0,0,.1)} .cb-invite-title{font-size:16px;font-weight:600;color:#333;margin-bottom:8px} .cb-invite-desc{font-size:13px;color:#666;line-height:1.8;margin-bottom:20px} .cb-invite-btn{display:inline-block;background:#F59E0B;color:#fff;border:none;border-radius:8px;padding:10px 24px;font-size:14px;cursor:pointer;text-decoration:none;transition:background .2s} .cb-invite-btn:hover{background:#D97706} .cb-contact{margin-top:24px;padding:16px;background:#fafafa;border-radius:8px;width:100%;max-width:280px} .cb-contact-title{font-size:12px;color:#999;margin-bottom:8px;text-align:left} .cb-contact-item{display:flex;align-items:center;font-size:12px;color:#666;margin-bottom:6px;text-align:left} .cb-contact-item:last-child{margin-bottom:0} .cb-contact-label{color:#999;width:60px;flex-shrink:0} .cb-contact-value{color:#333;word-break:break-all} .cb-contact-link{color:#F59E0B;text-decoration:none} .cb-contact-link:hover{text-decoration:underline} .cb-anchor-bar{display:flex;align-items:center;padding:6px 12px;background:#FFF8E1;border-bottom:1px solid #FFE082;flex-shrink:0;gap:8px} .cb-anchor-avatar{width:24px;height:24px;border-radius:50%;object-fit:cover} .cb-anchor-name{font-size:12px;color:#333;font-weight:500} .cb-anchor-medal{font-size:10px;color:#F59E0B;background:#FFF3CD;padding:1px 6px;border-radius:8px} .cb-anchor-detail-btn{margin-left:auto;font-size:11px;color:#F59E0B;cursor:pointer;padding:2px 8px;border:1px solid #F59E0B;border-radius:10px;transition:all .2s;text-decoration:none;line-height:1.4} .cb-anchor-detail-btn:hover{background:#F59E0B;color:#fff;text-decoration:none} .cb-toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.8);color:#fff;padding:8px 16px;border-radius:6px;font-size:12px;z-index:100001;animation:cb-fade-in .3s ease} .cb-toast-success{background:rgba(16,185,129,.95)} .cb-toast-error{background:rgba(239,68,68,.95)} @keyframes cb-fade-in{from{opacity:0;transform:translate(-50%,-10px)}to{opacity:1;transform:translate(-50%,0)}} .cb-cloud-btn{height:24px;cursor:pointer;margin-left:6px;margin-top:2px;display:flex;align-items:center;justify-content:center;padding:0 10px;border-radius:12px;background:linear-gradient(135deg,#F59E0B,#FBBF24);color:#fff;font-size:12px;font-weight:500;transition:all .2s;white-space:nowrap} .cb-cloud-btn:hover{opacity:.9;transform:translateY(-1px)} .cb-cloud-btn.active{box-shadow:0 2px 8px rgba(245,158,11,.4)} .cb-cloud-btn-text{line-height:1} `; if (typeof GM_addStyle === 'function') { GM_addStyle(PANEL_CSS); } else { const style = document.createElement('style'); style.textContent = PANEL_CSS; (document.head || document.documentElement).appendChild(style); } // ============ GM_xmlhttpRequest 封装 ============ // GM_xmlhttpRequest 的 withCredentials 参数对应原生 XHR 的 withCredentials(携带 cookie) // 注意:GM_xmlhttpRequest 本身会为 @connect 声明的域名自动携带同源 cookie, // 但跨子域(如 live.bilibili.com → api.live.bilibili.com)需要显式开启 function gmRequest(url, options = {}) { return new Promise((resolve, reject) => { const withCreds = (options.credentials === 'include' || options.withCredentials === true); GM_xmlhttpRequest({ method: options.method || 'GET', url: url, headers: options.headers || {}, data: options.body || null, withCredentials: withCreds, timeout: 30000, onload: (resp) => { resolve({ status: resp.status, json: () => { try { return JSON.parse(resp.responseText); } catch (e) { return {}; } }, text: () => resp.responseText }); }, onerror: (err) => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')) }); }); } // ============ 通过页面上下文 fetch 请求(保证 cookie 100% 携带) ============ // 原理:向页面注入一个临时 script,使用页面原生 fetch 发起请求, // 通过 window.postMessage 把结果传回油猴沙箱。这样完全绕过 GM_xmlhttpRequest // 的跨域 cookie 限制,使用页面自身的 credentials(与手动打开 API 地址行为一致) let _pageFetchSeq = 0; let _pageFetchDisabled = false; // CSP 拦截或环境不支持时自动禁用,后续直接走 gmRequest function pageFetch(url, options = {}) { return new Promise((resolve, reject) => { if (_pageFetchDisabled) { reject(new Error('pageFetch 已禁用(CSP 或环境不支持)')); return; } const seq = ++_pageFetchSeq; const channel = `__cb_fetch_${seq}_${Date.now()}`; const PAGE_FETCH_TIMEOUT_MS = 8000; // 8 秒超时(缩短,快速检测 CSP 拦截) let settled = false; const handler = (ev) => { if (ev.source !== window || !ev.data || ev.data.channel !== channel) return; window.removeEventListener('message', handler); if (settled) return; settled = true; clearTimeout(timer); const d = ev.data; if (d.ok) { resolve({ status: d.status, json: () => Promise.resolve(d.bodyJson ?? JSON.parse(d.bodyText || '{}')), text: () => Promise.resolve(d.bodyText || '') }); } else { reject(new Error(d.error || 'pageFetch failed')); } }; window.addEventListener('message', handler); // 超时保护:如果注入的 script 被 CSP 拦截或 fetch 挂起, // postMessage 永远不会收到,Promise 会永久挂起 const timer = setTimeout(() => { if (settled) return; settled = true; window.removeEventListener('message', handler); // 超时大概率是 CSP 拦截了内联 script,自动禁用避免后续重复等待 _pageFetchDisabled = true; reject(new Error(`pageFetch 超时 (${PAGE_FETCH_TIMEOUT_MS / 1000}s),已自动禁用`)); }, PAGE_FETCH_TIMEOUT_MS); const script = document.createElement('script'); script.textContent = ` (function(){ var channel = ${JSON.stringify(channel)}; var url = ${JSON.stringify(url)}; var opts = ${JSON.stringify({ method: options.method || 'GET', headers: options.headers || {}, body: options.body || undefined })}; opts.credentials = 'include'; fetch(url, opts).then(async function(r){ var text = await r.text(); var json = null; try { json = JSON.parse(text); } catch(e){} window.postMessage({ channel: channel, ok: true, status: r.status, bodyText: text, bodyJson: json }, location.origin); }).catch(function(e){ window.postMessage({ channel: channel, ok: false, error: String(e && e.message || e) }, location.origin); }); })(); `; try { (document.head || document.documentElement).appendChild(script); script.remove(); } catch (e) { // CSP 可能直接阻止 appendChild,立即失败并禁用 if (!settled) { settled = true; clearTimeout(timer); window.removeEventListener('message', handler); _pageFetchDisabled = true; reject(new Error('pageFetch script 注入失败(CSP),已自动禁用: ' + e.message)); } } }); } async function pageFetchJson(url, options = {}) { const resp = await pageFetch(url, options); const data = await resp.json(); return { status: resp.status, json: () => data, text: async () => (await resp.text()) }; } // ============ API 请求封装 ============ async function apiGet(path, params = {}) { const url = new URL(API_BASE + path); Object.keys(params || {}).forEach(k => { if (params[k] !== undefined && params[k] !== null && params[k] !== '') { url.searchParams.append(k, params[k]); } }); const resp = await gmRequest(url.toString(), { method: 'GET', headers: { 'Accept': 'application/json' } }); const data = resp.json(); if (resp.status === 429) { const retryAfter = data?.data?.retry_after || 60; throw new Error(`请求过于频繁,请 ${retryAfter} 秒后重试`); } return data; } async function apiPost(path, params = {}) { const url = new URL(API_BASE + path); const resp = await gmRequest(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(params) }); const data = resp.json(); if (resp.status === 429) { const retryAfter = data?.data?.retry_after || 60; throw new Error(`请求过于频繁,请 ${retryAfter} 秒后重试`); } return data; } // ============ Toast 提示 ============ function showToast(msg, type = 'success', duration = 2500) { const toast = document.createElement('div'); toast.className = `cb-toast cb-toast-${type}`; toast.textContent = msg; document.body.appendChild(toast); setTimeout(() => { toast.style.transition = 'opacity .3s'; toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, typeof type === 'number' ? type : duration); } // ============ 确认对话框 ============ function confirmDialog(message) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:100000;display:flex;align-items:center;justify-content:center;'; overlay.innerHTML = `
提示 云朵商城助手
${message}
`; overlay.querySelector('.cb-dlg-cancel').addEventListener('click', () => { overlay.remove(); resolve(false); }); overlay.querySelector('.cb-dlg-ok').addEventListener('click', () => { overlay.remove(); resolve(true); }); document.body.appendChild(overlay); }); } // ============ HTML 转义 ============ function esc(str) { if (str === null || str === undefined) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // ============ 状态管理 ============ const state = { roomId: '', panelCreated: false, panelVisible: false, activeTab: 'song', songData: null, goodsData: null, songTags: [], goodsCategories: [], contactInfo: null, anchorInfo: null, anchorNotFound: false, songLoading: false, goodsLoading: false, songKeyword: '', goodsKeyword: '', songTagId: '', goodsGroupId: '', initialized: false, initialLoading: false, hasSongsTab: false, hasGoodsTab: false }; // ============ 数据加载 ============ async function loadContactInfo() { if (state.contactInfo) return state.contactInfo; try { const resp = await apiGet('/contact'); if (resp.code === 200) { state.contactInfo = resp.data; } } catch (e) { log.warn('获取联系方式失败:', e.message); } return state.contactInfo; } async function loadSongList(page = 1, append = false) { if (state.songLoading) return; state.songLoading = true; try { const params = { room_id: state.roomId, page, page_size: 12 }; if (state.songKeyword) params.keyword = state.songKeyword; if (state.songTagId) params.tag_id = state.songTagId; const resp = await apiGet('/song/list', params); if (resp.code === 200) { if (append && state.songData) { state.songData.list = state.songData.list.concat(resp.data.list); state.songData.page = resp.data.page; } else { state.songData = resp.data; } if (resp.data.anchor_info) state.anchorInfo = resp.data.anchor_info; } else if (resp.code === 404) { state.anchorNotFound = true; } } catch (e) { log.error('加载歌单失败:', e.message); } finally { state.songLoading = false; } } async function loadGoodsList(page = 1, append = false) { if (state.goodsLoading) return; state.goodsLoading = true; try { const params = { room_id: state.roomId, page, page_size: 12 }; if (state.goodsKeyword) params.keyword = state.goodsKeyword; if (state.goodsGroupId) params.group_id = state.goodsGroupId; const resp = await apiGet('/goods/list', params); if (resp.code === 200) { if (append && state.goodsData) { state.goodsData.list = state.goodsData.list.concat(resp.data.list); state.goodsData.page = resp.data.page; } else { state.goodsData = resp.data; } if (resp.data.anchor_info) state.anchorInfo = resp.data.anchor_info; } else if (resp.code === 404) { state.anchorNotFound = true; } } catch (e) { log.error('加载商品失败:', e.message); } finally { state.goodsLoading = false; } } async function loadSongTags() { try { const resp = await apiGet('/song/tags', { room_id: state.roomId }); if (resp.code === 200) { state.songTags = resp.data.list || []; } } catch (e) { /* 忽略标签加载失败 */ } } async function loadGoodsCategories() { try { const resp = await apiGet('/goods/categories', { room_id: state.roomId }); if (resp.code === 200) { state.goodsCategories = resp.data.list || []; } } catch (e) { /* 忽略分类加载失败 */ } } async function initPanelData() { if (state.initialized) return; state.initialized = true; state.initialLoading = true; await Promise.all([ loadContactInfo(), loadSongList(1), loadGoodsList(1) ]); if (!state.anchorNotFound && state.anchorInfo) { await Promise.all([loadSongTags(), loadGoodsCategories()]); } state.initialLoading = false; const hasSongs = state.songData && state.songData.total > 0; const hasGoods = state.goodsData && state.goodsData.total > 0; state.hasSongsTab = hasSongs; state.hasGoodsTab = hasGoods; if (!state.hasSongsTab && state.hasGoodsTab) { state.activeTab = 'goods'; } else { state.activeTab = 'song'; } } // ============ 渲染函数 ============ function renderSongItem(song) { const tags = (song.tags || []).map(t => `${esc(t.tag_name)}` ).join(''); const profColors = { '生疏': '#ef4444', '一般': '#f59e0b', '熟悉': '#3b82f6', '熟练': '#8b5cf6', '精通': '#10b981' }; const profText = esc(song.proficiency_text || '未知'); const profColor = profColors[profText] || '#999'; return `
${esc(song.title)}
${song.artist ? `
${esc(song.artist)}
` : ''}
${profText} ${song.play_count > 0 ? `▶ ${song.play_count}` : ''}
${tags ? `
${tags}
` : ''}
`; } function renderGoodsItem(goods) { const stockText = goods.stock > 0 ? `库存 ${goods.stock}` : '已售罄'; const stockClass = goods.stock > 0 ? '' : 'sold-out'; return `
${goods.stock === 0 ? '
已售罄
' : ''}
${esc(goods.title)}
${goods.desc ? `
${esc(goods.desc)}
` : ''}
¥ ${goods.score} 积分
已售${goods.sold_numb}
`; } function renderLoading() { return `
加载中...
`; } function renderListLoading() { return `
加载中...
`; } function renderEmpty(text = '暂无数据') { return `
📋
${esc(text)}
`; } function renderInviteInterface() { const c = state.contactInfo || {}; return `
云朵商城
主播尚未上传内容
当前主播已开通云朵商城服务,
但还没有上传歌单或商品内容。
马上去推荐他/她上传内容吧!
前往云朵商城 ${renderContactBlock(c)}
`; } function renderNotFoundInterface() { const c = state.contactInfo || {}; return `
云朵商城
当前主播未使用云朵商城服务
马上去推荐给他/她吧!
让主播的歌单和商品直达粉丝直播间。
推荐给主播 ${renderContactBlock(c)}
`; } function renderContactBlock(c) { if (!c) return ''; const items = []; if (c.email) items.push(`
邮箱${esc(c.email)}
`); if (c.wechat) items.push(`
微信${esc(c.wechat)}
`); if (c.qq) items.push(`
QQ${esc(c.qq)}
`); if (c.qq_group) items.push(`
QQ群${esc(c.qq_group)}${c.qq_group_url ? ` 加入` : ''}
`); if (!items.length) return ''; return `
需要帮助 / 问题反馈 / 联系方式
${items.join('')} ${c.feedback_url ? `
问题反馈 →
` : ''}
`; } function renderAnchorBar() { if (!state.anchorInfo) return ''; const a = state.anchorInfo; const anchorId = a.anchor_id || a.uid || a.id || ''; return `
${esc(a.anchor_name || '主播')} ${a.fans_medal ? `${esc(a.fans_medal)}` : ''} 查看详情 →
`; } function renderSongTagBar() { if (!state.songTags.length) return ''; const tags = [`全部`]; state.songTags.forEach(t => { tags.push(`${esc(t.name)}`); }); return `
${tags.join('')}
`; } function renderGoodsCategoryBar() { if (!state.goodsCategories.length) return ''; const cats = [`全部`]; state.goodsCategories.forEach(g => { cats.push(`${esc(g.group_name)}`); }); return `
${cats.join('')}
`; } function renderSearchBar(type) { const keyword = type === 'song' ? state.songKeyword : state.goodsKeyword; const randomBtn = type === 'song' ? `` : ''; return `
${randomBtn}
`; } function renderSongContent() { if (state.songLoading && (!state.songData || !state.songData.list || state.songData.list.length === 0)) { return renderLoading(); } if (!state.songData || !state.songData.list || state.songData.list.length === 0) { return renderEmpty('暂无歌曲'); } let html = state.songData.list.map(renderSongItem).join(''); if (state.songLoading) { html += renderListLoading(); } return html; } function renderGoodsContent() { if (state.goodsLoading && (!state.goodsData || !state.goodsData.list || state.goodsData.list.length === 0)) { return renderLoading(); } if (!state.goodsData || !state.goodsData.list || state.goodsData.list.length === 0) { return renderEmpty('暂无商品'); } let html = `
${state.goodsData.list.map(renderGoodsItem).join('')}
`; if (state.goodsLoading) { html += renderListLoading(); } return html; } function renderTabBar() { if (!state.hasSongsTab && !state.hasGoodsTab) return ''; if (state.hasSongsTab && !state.hasGoodsTab) { return `
歌单
`; } if (!state.hasSongsTab && state.hasGoodsTab) { return `
商城
`; } const songCount = state.songData?.total || 0; const goodsCount = state.goodsData?.total || 0; return `
歌单${songCount}
商城${goodsCount}
`; } function renderPanel() { const panel = document.querySelector('.cb-panel'); if (!panel) return; if (state.initialLoading) { panel.innerHTML = `
☁️ 云朵商城
${renderLoading()}
`; bindPanelEvents(); return; } if (state.anchorNotFound) { panel.innerHTML = `
☁️ 云朵商城
${renderNotFoundInterface()}
`; bindPanelEvents(); return; } if (!state.hasSongsTab && !state.hasGoodsTab) { panel.innerHTML = `
☁️ 云朵商城
${renderInviteInterface()}
`; bindPanelEvents(); return; } let headerHtml = renderAnchorBar(); if (state.activeTab === 'song' && state.hasSongsTab) { headerHtml += renderSearchBar('song') + renderSongTagBar(); } else if (state.activeTab === 'goods' && state.hasGoodsTab) { headerHtml += renderSearchBar('goods') + renderGoodsCategoryBar(); } let contentHtml = ''; if (state.activeTab === 'song' && state.hasSongsTab) { contentHtml = `
${renderSongContent()}
`; } else if (state.activeTab === 'goods' && state.hasGoodsTab) { contentHtml = `
${renderGoodsContent()}
`; } panel.innerHTML = `
☁️ 云朵商城
${renderTabBar()}
${headerHtml}
${contentHtml}
`; bindPanelEvents(); setupInfiniteScroll(); } function setupInfiniteScroll() { const panelBody = document.querySelector('.cb-panel-body'); if (!panelBody) return; const existingHandler = panelBody.getAttribute('data-scroll-handler'); if (existingHandler && window[existingHandler]) { panelBody.removeEventListener('scroll', window[existingHandler]); delete window[existingHandler]; } const handlerName = `cb-scroll-handler-${Date.now()}`; window[handlerName] = () => { const isAtBottom = panelBody.scrollHeight - panelBody.scrollTop <= panelBody.clientHeight + 50; if (isAtBottom) { if (state.activeTab === 'song') { const data = state.songData; if (data && !state.songLoading && data.page < data.total_page) { loadSongList(data.page + 1, true).then(renderListContentOnly); } } else if (state.activeTab === 'goods') { const data = state.goodsData; if (data && !state.goodsLoading && data.page < data.total_page) { loadGoodsList(data.page + 1, true).then(renderListContentOnly); } } } }; panelBody.addEventListener('scroll', window[handlerName]); panelBody.setAttribute('data-scroll-handler', handlerName); } function renderListContentOnly() { const panelBody = document.querySelector('.cb-panel-body'); if (!panelBody) return; const scrollTop = panelBody.scrollTop; if (state.activeTab === 'song') { panelBody.innerHTML = `
${renderSongContent()}
`; } else if (state.activeTab === 'goods') { panelBody.innerHTML = `
${renderGoodsContent()}
`; } panelBody.scrollTop = scrollTop; bindListEvents(); } function bindListEvents() { const panel = document.querySelector('.cb-panel'); if (!panel) return; panel.querySelectorAll('.cb-song-item').forEach(item => { item.addEventListener('click', () => { const title = item.getAttribute('data-song-title'); const artist = item.getAttribute('data-song-artist'); const text = artist ? `点歌 ${title} - ${artist}` : `点歌 ${title}`; fillChatInput(text); }); }); panel.querySelectorAll('.cb-goods-card').forEach(item => { item.addEventListener('click', () => { const pid = item.getAttribute('data-product-id'); if (pid) { window.open(`${SITE_BASE}/shop/goods_detail/${pid}`, '_blank'); } }); }); } function bindPanelEvents() { const panel = document.querySelector('.cb-panel'); if (!panel) return; const closeBtn = panel.querySelector('.cb-panel-close'); if (closeBtn) { closeBtn.addEventListener('click', () => togglePanel(false)); } panel.querySelectorAll('.cb-tab').forEach(tab => { tab.addEventListener('click', () => { const target = tab.getAttribute('data-tab'); if (target && target !== state.activeTab) { state.activeTab = target; renderPanel(); } }); }); bindListEvents(); panel.querySelectorAll('.cb-search-btn').forEach(btn => { btn.addEventListener('click', () => { const type = btn.getAttribute('data-type'); const input = panel.querySelector(`.cb-search-input[data-type="${type}"]`); if (input) { if (type === 'song') { state.songKeyword = input.value.trim(); state.songData = null; panel.querySelectorAll('.cb-tag-item[data-tag-id]').forEach(t => t.classList.remove('active')); panel.querySelector('.cb-tag-item[data-tag-id=""]')?.classList.add('active'); loadSongList(1).then(renderListContentOnly); } else { state.goodsKeyword = input.value.trim(); state.goodsData = null; panel.querySelectorAll('.cb-tag-item[data-group-id]').forEach(c => c.classList.remove('active')); panel.querySelector('.cb-tag-item[data-group-id=""]')?.classList.add('active'); loadGoodsList(1).then(renderListContentOnly); } } }); }); panel.querySelectorAll('.cb-search-input').forEach(input => { input.addEventListener('keypress', e => { if (e.key === 'Enter') { const type = input.getAttribute('data-type'); panel.querySelector(`.cb-search-btn[data-type="${type}"]`)?.click(); } }); }); panel.querySelectorAll('.cb-random-btn').forEach(btn => { btn.addEventListener('click', handleRandomSong); }); panel.querySelectorAll('.cb-tag-item[data-tag-id]').forEach(tag => { tag.addEventListener('click', () => { panel.querySelectorAll('.cb-tag-item[data-tag-id]').forEach(t => t.classList.remove('active')); tag.classList.add('active'); state.songTagId = tag.getAttribute('data-tag-id') || ''; state.songData = null; loadSongList(1).then(renderListContentOnly); }); }); panel.querySelectorAll('.cb-tag-item[data-group-id]').forEach(cat => { cat.addEventListener('click', () => { panel.querySelectorAll('.cb-tag-item[data-group-id]').forEach(c => c.classList.remove('active')); cat.classList.add('active'); state.goodsGroupId = cat.getAttribute('data-group-id') || ''; state.goodsData = null; loadGoodsList(1).then(renderListContentOnly); }); }); initTagScrollButtons(panel); } function initTagScrollButtons(panel) { const wrapList = panel.querySelectorAll('.cb-tag-bar-wrap'); wrapList.forEach(wrap => { const tagBar = wrap.querySelector('.cb-tag-bar'); const leftBtn = wrap.querySelector('.cb-tag-scroll-left'); const rightBtn = wrap.querySelector('.cb-tag-scroll-right'); if (!tagBar || !leftBtn || !rightBtn) return; const updateButtons = () => { const { scrollLeft, scrollWidth, clientWidth } = tagBar; const canScrollLeft = scrollLeft > 2; const canScrollRight = scrollLeft + clientWidth < scrollWidth - 2; leftBtn.style.opacity = canScrollLeft ? '1' : '0'; leftBtn.style.pointerEvents = canScrollLeft ? 'auto' : 'none'; rightBtn.style.opacity = canScrollRight ? '1' : '0'; rightBtn.style.pointerEvents = canScrollRight ? 'auto' : 'none'; wrap.classList.toggle('has-overflow', scrollWidth > clientWidth + 2); }; leftBtn.addEventListener('click', () => { tagBar.scrollBy({ left: -120, behavior: 'smooth' }); }); rightBtn.addEventListener('click', () => { tagBar.scrollBy({ left: 120, behavior: 'smooth' }); }); tagBar.addEventListener('scroll', updateButtons); requestAnimationFrame(updateButtons); setTimeout(updateButtons, 100); }); } // ============ 弹幕框填充 ============ function findDanmakuInput() { const selectors = [ 'textarea.chat-input', 'div.chat-input-ctnr textarea', 'textarea.bili-dyn-list__item-input', 'input[type="text"][placeholder*="弹幕"]', 'textarea[placeholder*="发送弹幕"]', '.live-chat-input textarea', '#chat-input-area textarea', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el; } return null; } async function fillChatInput(text) { const textarea = document.querySelector('div.chat-input-ctnr textarea.chat-input') || findDanmakuInput(); if (textarea) { if (textarea.value.trim() !== '') { const ok = await confirmDialog('当前弹幕框不为空,是否继续填入?'); if (!ok) return; } text = text.replace(/<[^>]*>/g, ''); const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set || Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (setter) { setter.call(textarea, text); } else { textarea.value = text; } textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('change', { bubbles: true })); textarea.focus(); showToast('已填入弹幕框'); } else { const ok = await confirmDialog('未找到弹幕框,需要前往反馈吗?\n\n错误G-400-100,请联系开发者\n\n可能的原因:\n1. 未登录哔哩哔哩账号\n2. 脚本未适配当前版本哔哩哔哩'); if (ok) { window.open(state.contactInfo?.feedback_url || `${SITE_BASE}/`, '_blank'); } } } async function handleRandomSong(e) { const btn = e.currentTarget; if (!state.roomId) return; btn.disabled = true; btn.textContent = '🎲 随机中...'; try { const resp = await apiGet('/song/random', { room_id: state.roomId }); if (resp && resp.data && resp.data.song) { const song = resp.data.song; const text = song.artist ? `点歌 ${song.title} - ${song.artist}` : `点歌 ${song.title}`; await fillChatInput(text); log.success(`随机点歌: ${song.title} - ${song.artist}`); } } catch (e) { log.warn('随机获取歌曲失败:', e.message); showToast('随机获取失败', 'error'); } finally { btn.disabled = false; btn.textContent = '🎲 随机一首'; } } // ============ 面板控制 ============ async function togglePanel(show) { const panel = document.querySelector('.cb-panel'); const btn = document.querySelector('.cb-cloud-btn'); if (!panel) return; if (show === undefined) { state.panelVisible = !state.panelVisible; } else { state.panelVisible = show; } if (state.panelVisible) { panel.classList.add('active'); if (btn) btn.classList.add('active'); if (!state.initialized) { state.initialLoading = true; renderPanel(); await initPanelData(); renderPanel(); } } else { panel.classList.remove('active'); if (btn) btn.classList.remove('active'); } } function createPanel() { if (state.panelCreated) return; // 兼容多版本 B站直播间结构,依次尝试 const asideArea = document.querySelector('.aside-area') || document.querySelector('#aside-area-vm') || document.querySelector('.aside-area-vm') || document.querySelector('.chat-interface') || document.querySelector('.chat-list-panel') || document.querySelector('[class*="aside-area"]'); const chatControl = document.querySelector('.chat-control-panel') || document.querySelector('.chat-control') || document.querySelector('.bottom-ctnr'); if (!asideArea) { log.error('侧栏(弹幕列表)不存在,尝试 createPanel 失败'); return; } const panel = document.createElement('div'); panel.className = 'cb-panel'; if (chatControl) { panel.style.height = `calc(100% - ${chatControl.clientHeight}px)`; } panel.innerHTML = `
☁️ 云朵商城
${renderLoading()}
`; asideArea.appendChild(panel); state.panelCreated = true; log.success('面板已注入,父容器:', asideArea.className || asideArea.tagName); panel.querySelector('.cb-panel-close').addEventListener('click', () => togglePanel(false)); } function createButton() { // 尝试多种可能的按钮插入容器(按优先级) const insertTargets = [ document.querySelector('div.icon-left-part'), document.querySelector('.icon-left-part'), document.querySelector('.chat-input-left-area'), document.querySelector('.left-area'), document.querySelector('.chat-input-toolbar .left'), document.querySelector('.chat-control-panel .left'), document.querySelector('[class*="icon-left-part"]') ].filter(Boolean); if (insertTargets.length === 0) { log.warn('未找到任何可用的按钮插入容器'); return false; } // 去重,避免重复插入 if (document.querySelector('.cb-cloud-btn')) return true; const target = insertTargets[0]; const btnWrap = document.createElement('div'); btnWrap.className = 'cb-cloud-btn'; btnWrap.innerHTML = `云朵`; btnWrap.title = '云朵商城'; btnWrap.addEventListener('click', () => { createPanel(); togglePanel(); }); target.appendChild(btnWrap); log.success('入口按钮已添加,插入位置:', target.className || target.tagName); return true; } // ============ B站礼物数据获取与上传 ============ async function fetchBilibiliGifts(roomId) { try { const url = `https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList?platform=pc&room_id=${roomId}`; const resp = await gmRequest(url, { method: 'GET', headers: { 'Referer': `https://live.bilibili.com/${roomId}`, 'User-Agent': navigator.userAgent }, credentials: 'include' }); const data = resp.json(); if (data.code === 0 && data.data && data.data.gift_config && data.data.gift_config.base_config) { const gifts = data.data.gift_config.base_config.list || []; return { gifts }; } } catch (e) { log.error('获取B站礼物数据失败:', e.message); } return null; } async function fetchBilibiliBlindBoxes(gifts) { const blindBoxes = []; const blindGiftIds = gifts .filter(g => g.gift_type === 6) .map(g => g.id); for (const giftId of blindGiftIds) { try { const url = `https://api.live.bilibili.com/xlive/general-interface/v1/blindFirstWin/getInfo?gift_id=${giftId}`; const resp = await gmRequest(url, { method: 'GET', headers: { 'Referer': `https://live.bilibili.com/`, 'User-Agent': navigator.userAgent }, credentials: 'include' }); const data = resp.json(); if (data.code === 0 && data.data) { blindBoxes.push({ blind_gift_id: giftId, blind_price: data.data.blind_price || 0, blind_gift_name: data.data.blind_gift_name || '', note_text: data.data.note_text || '', gifts: data.data.gifts || [] }); } await new Promise(r => setTimeout(r, 1000)); } catch (e) { log.warn('获取盲盒概率数据失败 gift_id=', giftId, e.message); } } return blindBoxes; } async function fetchBilibiliGuards(roomId, ruid) { try { const url = `https://api.live.bilibili.com/xlive/app-room/v2/guardTab/topListNew?roomid=${roomId}&ruid=${ruid}&page=1&page_size=30`; const resp = await gmRequest(url, { method: 'GET', headers: { 'Referer': `https://live.bilibili.com/${roomId}`, 'User-Agent': navigator.userAgent }, credentials: 'include' }); const data = resp.json(); if (data.code === 0 && data.data) { return data.data; } } catch (e) { log.error('获取舰长列表失败:', e.message); } return null; } async function fetchAllGuards(roomId, ruid) { const allGuards = []; let currentPage = 1; const pageSize = 30; try { const firstData = await fetchBilibiliGuards(roomId, ruid); if (!firstData) return []; const totalCount = firstData.info?.num || 0; if (totalCount === 0) return []; if (firstData.top3 && firstData.top3.length > 0) { for (const item of firstData.top3) { allGuards.push({ ruid: String(item.ruid || ruid), uid: String(item.uinfo?.uid || ''), name: item.uinfo?.base?.name || '', face: item.uinfo?.base?.face || '', guard_level: item.uinfo?.guard?.level || item.uinfo?.medal?.guard_level || 0, accompany: item.accompany || 0, rank: item.rank || 0 }); } } if (firstData.list && firstData.list.length > 0) { for (const item of firstData.list) { allGuards.push({ ruid: String(item.ruid || ruid), uid: String(item.uinfo?.uid || ''), name: item.uinfo?.base?.name || '', face: item.uinfo?.base?.face || '', guard_level: item.uinfo?.guard?.level || item.uinfo?.medal?.guard_level || 0, accompany: item.accompany || 0, rank: item.rank || 0 }); } } const totalPages = Math.ceil(totalCount / pageSize); if (totalPages > 1) { for (currentPage = 2; currentPage <= totalPages; currentPage++) { await new Promise(r => setTimeout(r, 1000)); try { const url = `https://api.live.bilibili.com/xlive/app-room/v2/guardTab/topListNew?roomid=${roomId}&ruid=${ruid}&page=${currentPage}&page_size=${pageSize}`; const resp = await gmRequest(url, { method: 'GET', headers: { 'Referer': `https://live.bilibili.com/${roomId}`, 'User-Agent': navigator.userAgent }, credentials: 'include' }); const pageData = resp.json(); if (pageData.code === 0 && pageData.data && pageData.data.list) { for (const item of pageData.data.list) { allGuards.push({ ruid: String(item.ruid || ruid), uid: String(item.uinfo?.uid || ''), name: item.uinfo?.base?.name || '', face: item.uinfo?.base?.face || '', guard_level: item.uinfo?.guard?.level || item.uinfo?.medal?.guard_level || 0, accompany: item.accompany || 0, rank: item.rank || 0 }); } } } catch (e) { log.warn('获取舰长列表第', currentPage, '页失败:', e.message); } } } return allGuards; } catch (e) { log.error('获取舰长列表失败:', e.message); return allGuards; } } async function uploadGuardData(roomId, guards) { try { await apiPost('/live/guard_upload', { room_id: roomId, guards: guards }); log.success('舰长数据上传成功:', guards.length); } catch (e) { log.warn('舰长数据上传失败:', e.message); } } async function getAnchorUid(roomId) { try { const url = `https://api.live.bilibili.com/room/v1/Room/room_init?id=${roomId}`; const resp = await gmRequest(url, { method: 'GET', headers: { 'Referer': `https://live.bilibili.com/${roomId}`, 'User-Agent': navigator.userAgent }, credentials: 'include' }); const data = resp.json(); if (data.code === 0 && data.data && data.data.uid) { return String(data.data.uid); } } catch (e) { log.warn('获取主播UID失败:', e.message); } return ''; } async function startGuardTask(roomId, ruid) { async function runGuardTask() { try { const guards = await fetchAllGuards(roomId, ruid); if (guards.length > 0) { await uploadGuardData(roomId, guards); } } catch (e) { log.warn('舰长任务执行失败:', e.message); } } runGuardTask(); setInterval(runGuardTask, 30 * 60 * 1000); log.success('舰长任务已启动,每30分钟执行一次'); } async function fetchAndUploadGiftData(roomId) { try { const giftData = await fetchBilibiliGifts(roomId); if (giftData && giftData.gifts && giftData.gifts.length > 0) { await apiPost('/gift/upload', { room_id: roomId, gifts: giftData.gifts }); log.success('礼物数据上传成功:', giftData.gifts.length); const blindBoxData = await fetchBilibiliBlindBoxes(giftData.gifts); if (blindBoxData && blindBoxData.length > 0) { await apiPost('/blind_box/upload', { room_id: roomId, blind_boxes: blindBoxData }); log.success('盲盒数据上传成功:', blindBoxData.length); } } } catch (e) { log.warn('礼物数据获取或上传失败:', e.message); } } // ============ 流水同步任务(脚本猫版本,无 UI,无进度显示) ============ const BILL_SYNC_INTERVAL_MS = 10 * 1000; // 每 10 秒一次 const BILL_SYNC_PAGE_SIZE = 20; const BILL_SYNC_STATE_KEY = 'cb_bill_sync_state'; const BILL_SYNC_PENDING_YESTERDAY_KEY = 'cb_bill_sync_pending_yesterday'; // 日期工具 function billFormatDate(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function billParseDate(s) { const parts = String(s).split('-').map(Number); return new Date(parts[0], parts[1] - 1, parts[2]); } function billAddDays(dateStr, delta) { const d = billParseDate(dateStr); d.setDate(d.getDate() + delta); return billFormatDate(d); } function billGetYesterdayStr() { const d = new Date(); d.setDate(d.getDate() - 1); return billFormatDate(d); } function billGetTodayStr() { return billFormatDate(new Date()); } function billGetHalfYearAgoStr() { const d = new Date(); // 半年按 183 天计算,避免月份长度不一致带来的偏差 d.setDate(d.getDate() - 183); return billFormatDate(d); } function billDateLt(a, b) { return billParseDate(a).getTime() < billParseDate(b).getTime(); } function getBillSyncState() { const raw = GM_getValue(BILL_SYNC_STATE_KEY, null); if (raw === null || raw === undefined) return null; // ScriptCat / 部分脚本管理器对复杂对象不自动 JSON 序列化 // 兼容三种存储形式:null、已序列化的字符串、宿主原生支持的对象 if (typeof raw === 'string') { try { return JSON.parse(raw); } catch (e) { log.warn('[流水同步] 本地同步状态 JSON 解析失败,丢弃:', e.message); return null; } } // 原生对象:校验关键字段是否存在,否则视为已损坏 if (raw && typeof raw === 'object' && raw.breakpoint && raw.breakpoint.date) { return raw; } log.warn('[流水同步] 本地同步状态字段不完整,丢弃并重建'); return null; } function setBillSyncState(state) { // 显式 JSON 序列化,兼容 ScriptCat / Tampermonkey / Greasemonkey 等各种宿主 try { GM_setValue(BILL_SYNC_STATE_KEY, JSON.stringify(state)); } catch (e) { log.error('[流水同步] 本地同步状态写入失败:', e.message); } } function getPendingYesterdayFlag() { const raw = GM_getValue(BILL_SYNC_PENDING_YESTERDAY_KEY, 'false'); // 兼容布尔和字符串两种存储形态 return raw === true || raw === 'true' || raw === 1 || raw === '1'; } function setPendingYesterdayFlag(val) { // 统一存为布尔值(部分宿主会转成字符串,读取端已兼容) GM_setValue(BILL_SYNC_PENDING_YESTERDAY_KEY, !!val); } function initBillSyncState(bilibiliUid) { const state = { bilibili_uid: bilibiliUid, breakpoint: { date: billGetYesterdayStr(), last_id: null, has_more: false }, end_date: billGetHalfYearAgoStr(), completed: false, last_yesterday_check: '', last_today: billGetTodayStr(), total_records: 0, last_tick_at: 0, last_step: '', last_error: '' }; setBillSyncState(state); setPendingYesterdayFlag(true); return state; } // 调用B站流水API(优先通过页面上下文 fetch,保证 cookie 100% 携带;失败回退 GM_xmlhttpRequest) async function fetchBilibiliBillPage(beginTime, lastId) { const url = new URL('https://api.live.bilibili.com/xlive/revenue/v1/giftStream/getReceivedGiftStreamNextList'); url.searchParams.append('limit', String(BILL_SYNC_PAGE_SIZE)); url.searchParams.append('coin_type', '0'); url.searchParams.append('begin_time', beginTime); if (lastId !== null && lastId !== undefined && lastId !== '') { url.searchParams.append('last_id', String(lastId)); } let resp; try { // 首选:页面上下文 fetch,完全同源行为,cookie 齐全(包括 httpOnly) resp = await pageFetchJson(url.toString(), { method: 'GET', headers: { 'Accept': 'application/json', 'Referer': 'https://live.bilibili.com/' } }); } catch (e) { log.warn('[流水同步] pageFetch 失败,回退 GM_xmlhttpRequest:', e.message); resp = await gmRequest(url.toString(), { method: 'GET', headers: { 'Accept': 'application/json', 'Referer': 'https://live.bilibili.com/' }, credentials: 'include' }); } if (resp.status !== 200) { throw new Error(`B站流水API HTTP ${resp.status}`); } const data = resp.json(); if (data.code !== 0) { const err = new Error(`B站流水API错误 code=${data.code} msg=${data.message || data.msg}`); err.biliCode = data.code; throw err; } return data.data || { has_more: 0, list: [], total_hamster: 0 }; } // 上传到平台 async function uploadBillToPlatform(bilibiliUid, billDate, items, syncState) { const body = { bilibili_uid: bilibiliUid, bill_date: billDate, // items 即为B站API返回的原始list条目(未做任何转换/裁剪) // 服务端直接序列化存入 raw_json 作为原始数据存档 items: items, sync_state: { breakpoint_date: syncState.breakpoint.date, breakpoint_last_id: syncState.breakpoint.last_id, breakpoint_has_more: syncState.breakpoint.has_more, end_date: syncState.end_date, completed: syncState.completed, last_yesterday_check: syncState.last_yesterday_check || '', total_records: syncState.total_records || 0, last_error: syncState.last_error || '' } }; await apiPost('/live/bill_upload', body); } // 从云端恢复同步状态(卸载重装/切号后本地状态丢失时使用) async function fetchCloudSyncState(bilibiliUid) { try { const resp = await apiGet('/live/bill_sync_state', { bilibili_uid: bilibiliUid }); if (!resp || resp.code !== 200 || !resp.data || !resp.data.exists) return null; const d = resp.data; return { bilibili_uid: String(d.bilibili_uid), breakpoint: { date: String(d.breakpoint_date), last_id: (d.breakpoint_last_id !== null && d.breakpoint_last_id !== undefined) ? d.breakpoint_last_id : null, has_more: d.breakpoint_has_more === 1 }, end_date: String(d.end_date), completed: d.completed === true, last_yesterday_check: String(d.last_yesterday_check || ''), last_today: billGetTodayStr(), total_records: Number(d.total_records) || 0, last_tick_at: 0, last_step: '从云端恢复同步进度', last_error: String(d.last_error || '') }; } catch (e) { return null; } } // ============ 多端进度合并与上报 ============ // 比较两个同步状态的进度(同步方向:从昨天往 end_date 回溯,breakpoint.date 越小进度越大) // 返回: 1=a更靠后, -1=b更靠后, 0=相等 function compareProgress(a, b) { if (a.completed && !b.completed) return 1; if (!a.completed && b.completed) return -1; if (a.completed && b.completed) return 0; const aDate = a.breakpoint.date; const bDate = b.breakpoint.date; if (aDate && bDate && aDate !== bDate) { return aDate < bDate ? 1 : -1; } // 同日比较 last_id: null=未拉首页(最小), 0=空列表已处理(最大), 正数=中间 const aId = a.breakpoint.last_id; const bId = b.breakpoint.last_id; const aScore = (aId === null || aId === undefined) ? -1 : (aId === 0 ? Number.MAX_SAFE_INTEGER : aId); const bScore = (bId === null || bId === undefined) ? -1 : (bId === 0 ? Number.MAX_SAFE_INTEGER : bId); if (aScore > bScore) return 1; if (aScore < bScore) return -1; return 0; } // 合并本地与云端状态:取进度更大的断点,total_records 取 max function mergeSyncState(local, cloud) { if (!cloud) return local; if (!local) return cloud; const maxRecords = Math.max(local.total_records || 0, cloud.total_records || 0); if (compareProgress(local, cloud) >= 0) { return { ...local, total_records: maxRecords }; } return { ...cloud, total_records: maxRecords }; } // 向云端上报同步状态(无流水数据时也提交,确保多端进度同步) async function reportSyncState(bilibiliUid, syncState) { try { await uploadBillToPlatform(bilibiliUid, syncState.breakpoint.date, [], syncState); } catch (e) { // 上报失败不影响主流程,下次 tick 会重试 } } // 保存本地状态并上报云端:有 items 时上传流水+状态,无 items 时仅上报状态 async function saveAndReport(bilibiliUid, state, billDate, items) { setBillSyncState(state); try { if (items && items.length > 0) { await uploadBillToPlatform(bilibiliUid, billDate || state.breakpoint.date, items, state); } else { await reportSyncState(bilibiliUid, state); } } catch (e) { // 上传/上报失败不影响主流程 } } // 单次步进:先昨日,再断点续传 let billSyncTickRunning = false; // 防止 tick 重入(上一次 tick 的 await 还没结束) const BILL_SYNC_TICK_TIMEOUT_MS = 45000; // 单次 tick 最大执行时间 45 秒 async function billSyncTick(bilibiliUid) { if (billSyncTickRunning) { log.info('[流水同步] 上一次 tick 仍在执行,跳过本次'); return; } billSyncTickRunning = true; // 全局超时兜底:即使 billSyncTickReal 内部某个 await 永久挂起, // 也能在超时后强制释放锁,避免后续 tick 全部被跳过 const timeoutId = setTimeout(() => { if (billSyncTickRunning) { billSyncTickRunning = false; log.warn(`[流水同步] 单次 tick 超时 (${BILL_SYNC_TICK_TIMEOUT_MS / 1000}s),强制释放锁`); } }, BILL_SYNC_TICK_TIMEOUT_MS); try { await billSyncTickReal(bilibiliUid); } catch (err) { log.warn('[流水同步] tick 未捕获异常:', err.message); } finally { clearTimeout(timeoutId); billSyncTickRunning = false; } } async function billSyncTickReal(bilibiliUid) { let state = getBillSyncState(); if (!state || state.bilibili_uid !== bilibiliUid) { // 本地无状态或账号变更(首装/卸载重装/切号):优先从云端恢复,避免进度丢失 const cloudState = await fetchCloudSyncState(bilibiliUid); if (cloudState) { state = cloudState; setBillSyncState(state); // 仅当本地状态不存在(全新安装)时才强制昨日检查 // 否则如果只是损坏而重建,会导致"每次 tick 都重置昨日标志"的死循环 if (!state.last_yesterday_check) { setPendingYesterdayFlag(true); } log.info('[流水同步] 从云端恢复同步进度,断点:', state.breakpoint.date, ' last_id=', state.breakpoint.last_id); } else { state = initBillSyncState(bilibiliUid); log.info('[流水同步] 本地/云端均无状态,已全新初始化,起始日期:', state.breakpoint.date); } } else { // 本地有状态:从云端拉取最新并合并,保持多端进度同步(避免基于过时本地断点重复同步) const cloudState = await fetchCloudSyncState(bilibiliUid); if (cloudState) { const beforeDate = state.breakpoint.date; const beforeId = state.breakpoint.last_id; state = mergeSyncState(state, cloudState); setBillSyncState(state); if (beforeDate !== state.breakpoint.date || beforeId !== state.breakpoint.last_id) { log.info('[流水同步] 云端进度更新:本地断点', beforeDate, '#' + beforeId, '→ 合并后断点', state.breakpoint.date, '#' + state.breakpoint.last_id); } } } // 跨日检测:如果上次记录的"今日"与当前"今日"不同,说明跨越了 0 点 // 立即触发"请求昨日数据"流程(昨日 = 新的昨天) const todayStr = billGetTodayStr(); if (!state.last_today || state.last_today !== todayStr) { setPendingYesterdayFlag(true); // 如果之前已完成同步(达 200001 限制或到半年前),跨日后重新开启同步以拉取新的昨日 if (state.completed) { state.completed = false; log.info('[流水同步] 检测到跨日,重置 completed 标志以拉取新的昨日与后续历史'); } } state.last_today = todayStr; await saveAndReport(bilibiliUid, state); if (state.completed) { const pendingYesterday = getPendingYesterdayFlag(); if (!pendingYesterday) return; } const yesterday = billGetYesterdayStr(); // Step 1: 启动后先请求昨日数据(双重保险:只有 flag=true 且 昨日日期 未被检查过才执行) const pendingYesterday = getPendingYesterdayFlag(); const yesterdayNeedCheck = !state.last_yesterday_check || state.last_yesterday_check !== yesterday; if (pendingYesterday || yesterdayNeedCheck) { if (!pendingYesterday && yesterdayNeedCheck) { log.info('[流水同步] last_yesterday_check 过期,补拉昨日数据:', state.last_yesterday_check, '→', yesterday); } try { const respData = await fetchBilibiliBillPage(yesterday, null); const list = respData.list || []; if (list.length > 0) { state.total_records = (state.total_records || 0) + list.length; } state.last_yesterday_check = yesterday; state.last_tick_at = Date.now(); state.last_error = ''; state.last_step = `昨日检查 ${yesterday},${list.length} 条`; // ★★★ 关键:昨日数据拉取成功后,同步更新 breakpoint // 防止 Step 2 再从一个旧的(或相同的)日期重复拉第一页 state.breakpoint.date = yesterday; if (list.length > 0) { state.breakpoint.last_id = list[list.length - 1].id; } else { // 空列表:标记 last_id=0,让 Step 2 知道"昨日首页已经处理过" state.breakpoint.last_id = 0; } state.breakpoint.has_more = respData.has_more === 1; await saveAndReport(bilibiliUid, state, yesterday, list); setPendingYesterdayFlag(false); log.info(`[流水同步] 昨日数据已检查(${yesterday}),本页 ${list.length} 条,has_more=${state.breakpoint.has_more},累计 ${state.total_records}`); // 如果还有更多分页:本次 tick 不直接继续,留到下一个 tick 走 Step 2 分页 // 这样可以避免单次 tick 耗时过长导致重入 return; } catch (err) { // 200001:B站记录查询已达最大限制,视为同步完成 if (err && err.biliCode === 200001) { state.completed = true; state.end_date = yesterday; state.last_yesterday_check = yesterday; state.last_tick_at = Date.now(); state.last_error = ''; state.last_step = `已到达B站查询最大限制(200001),同步完成 @${yesterday}`; await saveAndReport(bilibiliUid, state); setPendingYesterdayFlag(false); log.success('[流水同步] 已到达B站查询最大限制(200001),同步完成 @' + yesterday); return; } // ★★★ 关键修复:昨日请求失败也要清除 pending 标志 // 否则下次 tick 仍会阻塞在 Step 1,历史数据永远无法同步 state.last_error = `昨日数据请求失败(将跳过,下一轮重试): ${err.message}`; state.last_tick_at = Date.now(); state.last_step = `昨日请求失败 ${yesterday}`; await saveAndReport(bilibiliUid, state); setPendingYesterdayFlag(false); log.warn('[流水同步]', state.last_error, '→ 已清除昨日阻塞标志,将继续历史同步'); // 不 return,让 Step 2 继续执行历史数据同步 } } // Step 2: 断点续传(历史数据回溯) if (state.completed) return; const bp = state.breakpoint; if (billDateLt(bp.date, state.end_date)) { state.completed = true; state.last_tick_at = Date.now(); state.last_step = `已达结束日期 ${state.end_date}`; await saveAndReport(bilibiliUid, state); log.success('[流水同步] 已完成至半年前(结束日期:', state.end_date, ')'); return; } try { let respData; let stepDesc; if (bp.last_id === null) { stepDesc = `日期内首页 ${bp.date}`; respData = await fetchBilibiliBillPage(bp.date, null); } else if (bp.last_id !== 0 && bp.has_more) { // last_id === 0 表示"该日期空列表已处理",必须跳过进入"步进至前一天" stepDesc = `日期内分页 ${bp.date} last_id=${bp.last_id}`; respData = await fetchBilibiliBillPage(bp.date, bp.last_id); } else { // 步进至前一天 const nextDate = billAddDays(bp.date, -1); if (billDateLt(nextDate, state.end_date)) { state.completed = true; state.last_tick_at = Date.now(); state.last_step = `已达结束日期 ${state.end_date}`; await saveAndReport(bilibiliUid, state); log.success('[流水同步] 已完成至半年前(结束日期:', state.end_date, ')'); return; } bp.date = nextDate; bp.last_id = null; bp.has_more = false; stepDesc = `日期步进至 ${bp.date}`; respData = await fetchBilibiliBillPage(bp.date, null); } const list = respData.list || []; if (list.length > 0) { state.total_records = (state.total_records || 0) + list.length; bp.last_id = list[list.length - 1].id; } else { // 列表为空:标记该日期已处理完毕,避免下一次 tick 因 last_id 仍为 null 而重复请求首页 bp.last_id = 0; } bp.has_more = respData.has_more === 1; state.last_tick_at = Date.now(); state.last_error = ''; state.last_step = stepDesc; await saveAndReport(bilibiliUid, state, bp.date, list); log.info(`[流水同步] ${stepDesc},本页 ${list.length} 条,has_more=${bp.has_more},累计 ${state.total_records}`); } catch (err) { // 200001:B站记录查询已达最大限制,视为同步完成 if (err && err.biliCode === 200001) { state.completed = true; state.end_date = bp.date; state.last_tick_at = Date.now(); state.last_error = ''; state.last_step = `已到达B站查询最大限制(200001),同步完成 @${bp.date}`; await saveAndReport(bilibiliUid, state); log.success('[流水同步] 已到达B站查询最大限制(200001),同步完成 @' + bp.date); return; } state.last_error = `断点同步失败: ${err.message}`; state.last_tick_at = Date.now(); state.last_step = `断点异常 @${bp.date}#${bp.last_id}`; await saveAndReport(bilibiliUid, state); log.warn('[流水同步]', state.last_error); } } // 启动流水同步任务:每 10 秒一次步进 async function startBillSyncTask() { const bilibiliUid = await getBilibiliUid(); if (!bilibiliUid) { log.warn('未获取到DedeUserID,跳过流水同步任务(请确认已登录B站)'); // 延迟 30 秒后再尝试一次,等待页面完全加载 cookie setTimeout(async () => { const retryUid = await getBilibiliUid(); if (retryUid) { log.success('延迟重试成功获取到 DedeUserID,启动流水同步任务'); startBillSyncTaskInner(retryUid); } else { log.warn('延迟重试仍未获取到 DedeUserID,流水同步任务不启动'); } }, 30 * 1000); return; } startBillSyncTaskInner(bilibiliUid); } function startBillSyncTaskInner(bilibiliUid) { // 每次脚本启动时,标记需要先检查昨日数据 setPendingYesterdayFlag(true); // 立即执行一次 billSyncTick(bilibiliUid).catch(err => log.warn('[流水同步] tick 异常:', err.message)); // 每 10 秒步进一次 setInterval(() => { billSyncTick(bilibiliUid).catch(err => log.warn('[流水同步] tick 异常:', err.message)); }, BILL_SYNC_INTERVAL_MS); log.success('[流水同步] 后台任务已启动,每 10 秒步进一次,UID:', bilibiliUid); } // ============ 用户信息同步任务(脚本猫版本) ============ // 每 1 分钟从后端获取需要拉取的 mid 列表,调用B站API获取详细信息后上报 const USER_INFO_SYNC_INTERVAL_MS = 60 * 1000; // 每 1 分钟一次 const USER_INFO_FETCH_LIMIT = 50; // 每次最多获取 50 个 mid const USER_INFO_SYNC_STARTUP_DELAY_MS = 30 * 1000; // 延迟 30 秒启动,避免与流水同步启动冲突 let userInfoSyncTimer = null; let userInfoSyncRunning = false; // 防止 tick 重入 // 调用B站用户信息API(优先通过页面上下文 fetch,失败回退 GM_xmlhttpRequest) async function fetchBilibiliUserCards(mids) { if (!mids || mids.length === 0) return []; const url = 'https://api.vc.bilibili.com/account/v1/user/cards?uids=' + mids.join(','); let resp; try { resp = await pageFetchJson(url, { method: 'GET', headers: { 'Accept': 'application/json', 'Referer': 'https://live.bilibili.com/' } }); } catch (e) { log.warn('[用户信息同步] pageFetch 失败,回退 GM_xmlhttpRequest:', e.message); resp = await gmRequest(url, { method: 'GET', headers: { 'Accept': 'application/json', 'Referer': 'https://live.bilibili.com/' }, credentials: 'include' }); } if (resp.status !== 200) { throw new Error(`B站用户信息API HTTP ${resp.status}`); } const data = resp.json(); if (data.code !== 0) { throw new Error(`B站用户信息API错误 code=${data.code} msg=${data.message || data.msg}`); } return data.data || []; } // 单次同步步进 async function userInfoSyncTick() { // 重入保护:上一次 tick 还未结束则跳过本次 if (userInfoSyncRunning) return; userInfoSyncRunning = true; try { // 需要登录态:未登录直接跳过 const bilibiliUid = await getBilibiliUid(); if (!bilibiliUid) return; // 1. 从后端获取需要拉取的 mid 列表 const result = await apiGet('/live/user_info_fetch_list'); if (!result || result.code !== 200 || !result.data || !Array.isArray(result.data.mids) || result.data.mids.length === 0) { return; } const mids = result.data.mids.slice(0, USER_INFO_FETCH_LIMIT); if (mids.length === 0) return; // 2. 调用B站API获取用户详细信息 const userCards = await fetchBilibiliUserCards(mids); if (!userCards || userCards.length === 0) return; // 3. 上报到后端(即便部分失败也不影响整体流程,后端会兜底处理) await apiPost('/live/user_info_upload', { items: userCards }); } catch (e) { // 用户信息同步失败不影响主流程 log.warn('[用户信息同步] tick 异常:', e.message); } finally { userInfoSyncRunning = false; } } function startUserInfoSyncTimer() { if (userInfoSyncTimer) return; // 延迟启动,避免与流水同步启动冲突 setTimeout(() => { userInfoSyncTick().catch(() => {}); userInfoSyncTimer = setInterval(() => { userInfoSyncTick().catch(() => {}); }, USER_INFO_SYNC_INTERVAL_MS); }, USER_INFO_SYNC_STARTUP_DELAY_MS); log.info('[用户信息同步] 后台任务已调度,每 1 分钟步进一次'); } function stopUserInfoSyncTimer() { if (userInfoSyncTimer) { clearInterval(userInfoSyncTimer); userInfoSyncTimer = null; } } // ============ 脚本猫/油猴菜单命令 ============ if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('☁️ 打开/关闭云朵商城面板', () => { createPanel(); togglePanel(); }); GM_registerMenuCommand('⚙️ 切换插件启用状态', () => { enabled = !enabled; GM_setValue('enabled', enabled); showToast(`插件已${enabled ? '启用' : '禁用'},刷新页面后生效`, enabled ? 'success' : 'error'); }); GM_registerMenuCommand('🔄 重置流水同步进度', async () => { if (!confirm('确定要重置流水同步进度吗?\n\n将清空本地和云端的同步断点,下次同步将从昨日重新开始。\n已上传的流水数据不会被删除。')) { return; } const bilibiliUid = await getBilibiliUid(); if (!bilibiliUid) { showToast('未检测到B站登录状态,请先登录', 'error'); return; } try { // 1. 清空本地状态 GM_setValue(BILL_SYNC_STATE_KEY, null); GM_setValue(BILL_SYNC_PENDING_YESTERDAY_KEY, true); // 2. 清空云端断点 await apiPost('/live/bill_sync_reset', { bilibili_uid: String(bilibiliUid) }); showToast('同步进度已重置,下次同步将从昨日重新开始', 'success'); } catch (e) { showToast('重置失败: ' + e.message, 'error'); } }); } // ============ 初始化 ============ function tryCreateButtonOnce() { if (document.querySelector('.cb-cloud-btn')) return true; const ok = createButton(); if (ok) log.info('通过轮询/兜底成功注入入口按钮'); return !!ok; } // 启动注入重试:轮询 + 双层 MutationObserver(asideVm + document.body 兜底) function startInjectionRetry() { // 1. 立即尝试一次 tryCreateButtonOnce(); // 2. asideVm 级别监听(如果能找到它) const asideVm = document.getElementById('aside-area-vm') || document.querySelector('.aside-area-vm') || document.querySelector('.aside-area'); if (asideVm) { const observer = new MutationObserver(() => { tryCreateButtonOnce(); }); observer.observe(asideVm, { attributes: true, childList: true, subtree: true }); log.info('已挂载 asideVm MutationObserver 等待目标元素出现'); } // 3. document.body 全局监听兜底(B站 SPA 切换路由时也能触发) try { const bodyObserver = new MutationObserver(() => { tryCreateButtonOnce(); }); bodyObserver.observe(document.documentElement || document.body, { attributes: false, childList: true, subtree: true }); log.info('已挂载全局 document.body MutationObserver 兜底'); } catch (e) { log.warn('全局 MutationObserver 挂载失败:', e.message); } // 4. 定时轮询兜底:每 2 秒尝试一次,持续 60 秒 let ticks = 0; const pollTimer = setInterval(() => { ticks++; const ok = tryCreateButtonOnce(); if (ok || ticks >= 30) { clearInterval(pollTimer); if (!ok) log.warn('注入重试超时(60s),仍未找到入口按钮容器'); } }, 2000); log.info('已启动注入轮询(每2秒,最多60秒)'); } async function init() { log.info('版本号:', VERSION); log.info('开始加载...'); if (!enabled) { log.info('插件已在设置中禁用,跳过加载'); return; } // 流水同步任务:在所有 bilibili 相关页面启动,不依赖房间号 // 不显示进度,仅在后台运行;每 10 秒一次步进 startBillSyncTask(); // 用户信息同步任务:在所有 bilibili 相关页面启动,不依赖房间号 // 后台静默运行;每 1 分钟一次步进,延迟 30 秒启动 startUserInfoSyncTimer(); const pathRoomId = window.location.pathname.replace(/[^\d]/g, ''); if (pathRoomId.length === 0) { log.warn('非直播间页面,仅启动后台同步任务,不注入面板/按钮'); return; } state.roomId = pathRoomId; log.success('当前房间号:', state.roomId); // 启动按钮/面板注入(包含重试和兜底机制) startInjectionRetry(); fetchAndUploadGiftData(state.roomId); const ruid = await getAnchorUid(state.roomId); if (ruid) { log.success('获取到主播UID:', ruid); startGuardTask(state.roomId, ruid); } else { log.warn('未获取到主播UID,跳过舰长任务'); } log.success('加载完成,注入机制已启动,等待目标元素出现...'); } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return ''; } // 通过页面上下文尝试获取 cookie(绕过沙箱对某些 cookie 的可见性限制) // 返回 Promise function pageGetCookie(name) { return new Promise((resolve) => { if (_pageFetchDisabled) { resolve(''); return; } try { const seq = ++_pageFetchSeq; const channel = `__cb_cookie_${seq}_${Date.now()}`; const handler = (ev) => { if (ev.source !== window || !ev.data || ev.data.channel !== channel) return; window.removeEventListener('message', handler); resolve(ev.data.value || ''); }; window.addEventListener('message', handler); const script = document.createElement('script'); script.textContent = ` (function(){ var channel = ${JSON.stringify(channel)}; var name = ${JSON.stringify(name)}; var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); window.postMessage({ channel: channel, value: (match && match[2]) ? decodeURIComponent(match[2]) : '' }, location.origin); })(); `; (document.head || document.documentElement).appendChild(script); script.remove(); // 兜底:300ms 超时返回空 setTimeout(() => { window.removeEventListener('message', handler); resolve(''); }, 300); } catch (e) { resolve(''); } }); } // 获取 B站 DedeUserID(登录UID),按优先级尝试多种方式 async function getBilibiliUid() { // 1. 优先直接从 document.cookie 读(绝大多数情况可行) let uid = getCookie('DedeUserID'); if (uid) return uid; // 2. 通过页面上下文再读一次(应对油猴沙箱 cookie 可见性差异) try { uid = await pageGetCookie('DedeUserID'); if (uid) { log.info('通过页面上下文成功获取 DedeUserID:', uid); return uid; } } catch (e) { /* ignore */ } // 3. 尝试从 window 对象里的 __INITIAL_STATE__ 等全局数据提取 try { const seq = ++_pageFetchSeq; const channel = `__cb_uid_${seq}_${Date.now()}`; uid = await new Promise((resolve) => { const handler = (ev) => { if (ev.source !== window || !ev.data || ev.data.channel !== channel) return; window.removeEventListener('message', handler); resolve(ev.data.uid || ''); }; window.addEventListener('message', handler); const script = document.createElement('script'); script.textContent = ` (function(){ var channel = ${JSON.stringify(channel)}; var uid = ''; try { if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.space && window.__INITIAL_STATE__.space.mid) { uid = String(window.__INITIAL_STATE__.space.mid); } else if (window.__BILI_USER_ID__) { uid = String(window.__BILI_USER_ID__); } else if (window.BilibiliLive && window.BilibiliLive.UID) { uid = String(window.BilibiliLive.UID); } } catch(e){} window.postMessage({ channel: channel, uid: uid }, location.origin); })(); `; (document.head || document.documentElement).appendChild(script); script.remove(); setTimeout(() => { window.removeEventListener('message', handler); resolve(''); }, 500); }); if (uid) { log.info('通过 window.__INITIAL_STATE__ 获取到 DedeUserID:', uid); return uid; } } catch (e) { /* ignore */ } log.warn('未能获取到 DedeUserID(B站UID),可能未登录或页面结构变更'); return ''; } async function getPlatformUid() { try { const url = `${API_BASE.replace('/api/v1/open', '')}/api/v1/user/info`; const resp = await gmRequest(url, { method: 'GET', headers: { 'Accept': 'application/json' }, credentials: 'include' }); const data = resp.json(); if (data.code === 200 && data.data && data.data.uid) { return String(data.data.uid); } } catch (e) { log.warn('获取平台UID失败:', e.message); } return ''; } async function fetchFollowedLiveList() { try { const url = 'https://api.live.bilibili.com/xlive/web-ucenter/v1/xfetter/GetWebList?hit_ab=true'; let resp; try { resp = await pageFetchJson(url, { method: 'GET', headers: { 'Referer': 'https://live.bilibili.com/', 'User-Agent': navigator.userAgent } }); } catch (e) { log.warn('[关注列表] pageFetch 失败,回退 GM_xmlhttpRequest:', e.message); resp = await gmRequest(url, { method: 'GET', headers: { 'Referer': 'https://live.bilibili.com/', 'User-Agent': navigator.userAgent }, credentials: 'include' }); } const data = resp.json(); if (data.code === 0 && data.data) { let list = []; if (data.data.rooms && data.data.rooms.length > 0) { list = data.data.rooms; } else if (data.data.list && data.data.list.length > 0) { list = data.data.list; } if (list.length > 0) { const uids = list.map(item => item.uid).filter(uid => uid && uid > 0); log.info('获取到', list.length, '个房间,准备查询', uids.length, '个主播状态'); if (uids.length > 0) { const statusData = await fetchRoomStatusByUids(uids); const result = list.map(room => { const uid = room.uid; if (statusData[uid]) { const status = statusData[uid]; return { ...room, ...status }; } return room; }); return result; } } return list; } } catch (e) { log.error('获取关注直播列表失败:', e.message); } return []; } async function fetchRoomStatusByUids(uids) { try { const url = 'https://api.live.bilibili.com/room/v1/Room/get_status_info_by_uids'; const params = uids.map(uid => `uids[]=${uid}`).join('&'); const fullUrl = `${url}?${params}`; let resp; try { resp = await pageFetchJson(fullUrl, { method: 'GET', headers: { 'Referer': 'https://live.bilibili.com/', 'User-Agent': navigator.userAgent } }); } catch (e) { log.warn('[直播间状态] pageFetch 失败,回退 GM_xmlhttpRequest:', e.message); resp = await gmRequest(fullUrl, { method: 'GET', headers: { 'Referer': 'https://live.bilibili.com/', 'User-Agent': navigator.userAgent }, credentials: 'include' }); } const data = resp.json(); if (data.code === 0 && data.data) { return data.data; } } catch (e) { log.error('批量查询直播间状态失败:', e.message); } return {}; } async function uploadLiveData(rooms, bilibiliUid, platformUid) { try { await apiPost('/live/upload', { bilibili_uid: bilibiliUid, platform_uid: platformUid, rooms: rooms }); log.success('直播数据上传成功:', rooms.length); } catch (e) { log.warn('直播数据上传失败:', e.message); } } async function uploadFollowingList(following, platformUid, bilibiliUid) { try { await apiPost('/live/following_upload', { platform_uid: platformUid, bilibili_uid: bilibiliUid, following: following }); log.success('关注列表上传成功:', following.length); } catch (e) { log.warn('关注列表上传失败:', e.message); } } async function startLiveFollowTask() { const bilibiliUid = await getBilibiliUid(); if (!bilibiliUid) { log.warn('未获取到DedeUserID,跳过直播关注任务'); // 延迟 30 秒后再尝试一次 setTimeout(async () => { const retryUid = await getBilibiliUid(); if (retryUid) { log.success('延迟重试成功获取到 DedeUserID,启动直播关注任务'); startLiveFollowTaskInner(retryUid); } }, 30 * 1000); return; } startLiveFollowTaskInner(bilibiliUid); } function startLiveFollowTaskInner(bilibiliUid) { (async () => { let platformUid = await getPlatformUid(); let lastUploadTime = 0; async function runTask() { try { const now = Date.now(); if (now - lastUploadTime < 10 * 60 * 1000) return; lastUploadTime = now; const liveList = await fetchFollowedLiveList(); if (liveList.length > 0) { const rooms = liveList.map(item => ({ room_id: String(item.roomid || item.room_id || ''), uid: String(item.uid || ''), uname: item.uname || item.name || item.nickname || '', uface: item.uface || item.face || '', live_status: item.live_status || 0, title: item.title || item.roomname || '', area_id: item.area || item.area_id || 0, area_name: item.area_name || '', area_v2_id: item.area_v2_id || item.parent_area_id || 0, area_v2_name: item.area_v2_name || item.parent_area_name || '', cover_from_user: item.cover_from_user || item.cover || '', liveTime: item.liveTime || item.live_time || item.start_time || '' })); await uploadLiveData(rooms, bilibiliUid, platformUid); } if (!platformUid) { platformUid = await getPlatformUid(); } if (platformUid && liveList.length > 0) { const following = liveList.map(item => ({ uid: String(item.uid || ''), room_id: String(item.roomid || item.room_id || ''), uname: item.uname || item.name || item.nickname || '', uface: item.uface || item.face || '' })); await uploadFollowingList(following, platformUid, bilibiliUid); } } catch (e) { log.warn('直播关注任务执行失败:', e.message); } } runTask(); setInterval(runTask, 10 * 60 * 1000); log.success('直播关注任务已启动,每10分钟执行一次,UID:', bilibiliUid); })(); } init(); startLiveFollowTask(); })();