// ==UserScript== // @name B站直播收益礼物清单一键导出 Excel // @namespace https://link.bilibili.com/ // @version 1.2.0 // @description 自动逐页采集B站直播收益中的礼物清单,并导出为包含明细和汇总表的XLSX文件。 // @author suuperfishy // @homepageURL https://github.com/suuperfishy/bilibili-live-gift-exporter // @supportURL https://github.com/suuperfishy/bilibili-live-gift-exporter/issues // @match *://link.bilibili.com/* // @run-at document-idle // @grant none // ==/UserScript== (() => { 'use strict'; const ROUTE = '/live-data/gift-list'; const BUTTON_ID = 'bili-gift-export-xlsx'; const STATUS_ID = 'bili-gift-export-status'; const PAGE_TIMEOUT_MS = 15000; const MAX_PAGE_RETRIES = 3; let exporting = false; let cancelRequested = false; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function cleanText(value) { return String(value ?? '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); } function currentPage() { const active = document.querySelector('.list-pagination .pager-item.active'); return Number.parseInt(cleanText(active?.textContent), 10) || 0; } function pageSignature() { const first = document.querySelector('table.simple-grid tbody tr'); return first ? [...first.querySelectorAll('td')] .map((cell) => cleanText(cell.textContent)) .join('\u241f') : ''; } function readPageRows() { return [...document.querySelectorAll('table.simple-grid tbody tr')] .map((tr) => [...tr.querySelectorAll('td')].map((td) => cleanText(td.textContent)) ) .filter((row) => row.length === 7); } function getPaginationInfo() { const text = cleanText( document.querySelector('.list-pagination .pager-total')?.textContent ); const match = text.match(/共\s*(\d+)\s*页\s*\/\s*(\d+)\s*个/); if (!match) { throw new Error('无法读取总页数和记录数,请确认列表已经加载完成。'); } return { totalPages: Number(match[1]), expectedRows: Number(match[2]), }; } function findPagerButton(label) { return [...document.querySelectorAll('.list-pagination button.pager-btn')] .find((button) => cleanText(button.textContent) === label); } async function waitForPage(targetPage, previousSignature) { const deadline = Date.now() + PAGE_TIMEOUT_MS; while (Date.now() < deadline) { if (cancelRequested) { throw new Error('__CANCELLED__'); } const page = currentPage(); const signature = pageSignature(); if ( page === targetPage && signature && signature !== previousSignature ) { await sleep(180); return; } await sleep(120); } throw new Error(`等待第 ${targetPage} 页加载超时`); } async function moveOnePage(label, targetPage) { let lastError; for (let attempt = 1; attempt <= MAX_PAGE_RETRIES; attempt += 1) { const before = pageSignature(); const button = findPagerButton(label); if (!button || button.disabled) { throw new Error(`找不到可用的“${label}”按钮。`); } button.click(); try { await waitForPage(targetPage, before); return; } catch (error) { if (error.message === '__CANCELLED__') { throw error; } lastError = error; await sleep(500 * attempt); } } throw lastError; } async function returnToFirstPage(updateProgress) { let page = currentPage(); if (!page) { throw new Error('无法识别当前页码。'); } while (page > 1) { if (cancelRequested) { throw new Error('__CANCELLED__'); } updateProgress(`正在返回第1页(当前第${page}页)`); await moveOnePage('上一页', page - 1); page = currentPage(); } } function numeric(value) { const number = Number(String(value).replace(/,/g, '')); return Number.isFinite(number) ? number : 0; } function buildUserSummary(rows) { const result = new Map(); rows.forEach((row) => { const key = row[2] || '(空昵称)'; const current = result.get(key) || { records: 0, quantity: 0, gold: 0, }; current.records += 1; current.quantity += numeric(row[5]); current.gold += numeric(row[6]); result.set(key, current); }); return [...result.entries()] .map(([name, value]) => [ name, value.records, value.quantity, value.gold, ]) .sort( (a, b) => b[3] - a[3] || b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), 'zh-CN') ); } function buildGiftSummary(rows) { const result = new Map(); rows.forEach((row) => { const key = row[4] || '(空礼物名)'; const current = result.get(key) || { records: 0, quantity: 0, gold: 0, }; current.records += 1; current.quantity += numeric(row[5]); current.gold += numeric(row[6]); result.set(key, current); }); return [...result.entries()] .map(([name, value]) => [ name, value.records, value.quantity, value.gold, ]) .sort( (a, b) => b[3] - a[3] || b[2] - a[2] || String(a[0]).localeCompare(String(b[0]), 'zh-CN') ); } function xmlEscape(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function columnName(index) { let result = ''; let value = index + 1; while (value > 0) { value -= 1; result = String.fromCharCode(65 + (value % 26)) + result; value = Math.floor(value / 26); } return result; } function makeWorksheetXml(sheet) { const rows = sheet.rows; const maxColumns = Math.max(1, ...rows.map((row) => row.length)); const endRef = `${columnName(maxColumns - 1)}${Math.max(1, rows.length)}`; const numericColumns = new Set(sheet.numericColumns || []); const columns = (sheet.widths || []) .map( (width, index) => `` ) .join(''); const rowXml = rows .map((row, rowIndex) => { const cells = row .map((value, columnIndex) => { const ref = `${columnName(columnIndex)}${rowIndex + 1}`; const isNumeric = rowIndex > 0 && numericColumns.has(columnIndex) && value !== '' && value !== null && Number.isFinite(Number(value)); const style = rowIndex === 0 ? 1 : isNumeric ? 3 : 2; if (isNumeric) { return `${Number(value)}`; } return `${xmlEscape(value)}`; }) .join(''); return `${cells}`; }) .join(''); return ` ${columns} ${rowXml} `; } function crc32(bytes) { if (!crc32.table) { crc32.table = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let c = n; for (let k = 0; k < 8; k += 1) { c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; } crc32.table[n] = c >>> 0; } } let crc = 0xFFFFFFFF; for (const byte of bytes) { crc = crc32.table[(crc ^ byte) & 0xFF] ^ (crc >>> 8); } return (crc ^ 0xFFFFFFFF) >>> 0; } function le16(value) { return new Uint8Array([ value & 0xFF, (value >>> 8) & 0xFF, ]); } function le32(value) { return new Uint8Array([ value & 0xFF, (value >>> 8) & 0xFF, (value >>> 16) & 0xFF, (value >>> 24) & 0xFF, ]); } function concatBytes(parts) { const length = parts.reduce((sum, part) => sum + part.length, 0); const result = new Uint8Array(length); let offset = 0; parts.forEach((part) => { result.set(part, offset); offset += part.length; }); return result; } function dosDateTime(date) { const year = Math.max(1980, date.getFullYear()); return { time: ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | (Math.floor(date.getSeconds() / 2) & 31), date: (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31), }; } function createZip(files) { const encoder = new TextEncoder(); const localParts = []; const centralParts = []; const now = dosDateTime(new Date()); let offset = 0; files.forEach((file) => { const name = encoder.encode(file.name); const data = typeof file.content === 'string' ? encoder.encode(file.content) : file.content; const crc = crc32(data); const localHeader = concatBytes([ le32(0x04034B50), le16(20), le16(0x0800), le16(0), le16(now.time), le16(now.date), le32(crc), le32(data.length), le32(data.length), le16(name.length), le16(0), name, ]); localParts.push(localHeader, data); const centralHeader = concatBytes([ le32(0x02014B50), le16(20), le16(20), le16(0x0800), le16(0), le16(now.time), le16(now.date), le32(crc), le32(data.length), le32(data.length), le16(name.length), le16(0), le16(0), le16(0), le16(0), le32(0), le32(offset), name, ]); centralParts.push(centralHeader); offset += localHeader.length + data.length; }); const centralDirectory = concatBytes(centralParts); const localData = concatBytes(localParts); const end = concatBytes([ le32(0x06054B50), le16(0), le16(0), le16(files.length), le16(files.length), le32(centralDirectory.length), le32(localData.length), le16(0), ]); return concatBytes([ localData, centralDirectory, end, ]); } function createXlsx(sheets) { const sheetOverrides = sheets .map( (_, index) => `` ) .join(''); const sheetEntries = sheets .map( (sheet, index) => `` ) .join(''); const sheetRelations = sheets .map( (_, index) => `` ) .join(''); const styleRelationId = sheets.length + 1; const files = [ { name: '[Content_Types].xml', content: ` ${sheetOverrides} `, }, { name: '_rels/.rels', content: ` `, }, { name: 'xl/workbook.xml', content: ` ${sheetEntries} `, }, { name: 'xl/_rels/workbook.xml.rels', content: ` ${sheetRelations} `, }, { name: 'xl/styles.xml', content: ` `, }, ]; sheets.forEach((sheet, index) => { files.push({ name: `xl/worksheets/sheet${index + 1}.xml`, content: makeWorksheetXml(sheet), }); }); return createZip(files); } function downloadXlsx(rows, info) { const detailHeader = [ '收礼身份', '直播间ID', '送礼用户昵称', '收礼时间', '礼物名称', '数量', '金仓鼠数', ]; const userHeader = [ '送礼用户昵称', '记录数', '礼物数量合计', '金仓鼠数合计', ]; const giftHeader = [ '礼物名称', '记录数', '礼物数量合计', '金仓鼠数合计', ]; const userSummary = buildUserSummary(rows); const giftSummary = buildGiftSummary(rows); const goldTotal = rows.reduce( (sum, row) => sum + numeric(row[6]), 0 ); const rmbTotal = Number( (goldTotal / 1000).toFixed(3) ); const uniqueUserCount = new Set( rows .map((row) => row[2]) .filter((nickname) => nickname) ).size; const exportedAt = new Date().toLocaleString('zh-CN', { hour12: false, }); const status = rows.length === info.expectedRows ? '数量核对一致' : `数量不一致,页面显示${info.expectedRows}条,实际导出${rows.length}条`; const metadata = [ ['项目', '内容'], ['页面筛选说明', info.filterSummary], ['日期范围', info.dateRange], ['用户昵称筛选', info.nickname || '未筛选'], ['页面总页数', info.totalPages], ['页面显示记录数', info.expectedRows], ['实际导出记录数', rows.length], ['赠送礼物用户数', uniqueUserCount], ['金仓鼠总数', goldTotal], ['折算人民币金额(RMB)', rmbTotal], ['换算关系', '1000金仓鼠 = 1 RMB'], ['核对状态', status], ['导出时间', exportedAt], [ '使用说明', '礼物明细按B站页面当前筛选条件导出;用户汇总和礼物汇总由明细自动计算。', ], ]; const workbook = createXlsx([ { name: '礼物明细', rows: [detailHeader, ...rows], numericColumns: [5, 6], widths: [12, 14, 24, 21, 22, 10, 14], }, { name: '用户汇总', rows: [userHeader, ...userSummary], numericColumns: [1, 2, 3], widths: [28, 12, 16, 18], }, { name: '礼物汇总', rows: [giftHeader, ...giftSummary], numericColumns: [1, 2, 3], widths: [28, 12, 16, 18], }, { name: '导出信息', rows: metadata, numericColumns: [1], widths: [22, 70], }, ]); const datePart = ( info.dateRange || new Date().toISOString().slice(0, 10) ) .replace(/至/g, '_') .replace(/[^\d_-]/g, ''); const warningPart = rows.length === info.expectedRows ? '' : '_需复核'; const filename = `B站直播礼物明细_${datePart || '当前筛选'}${warningPart}.xlsx`; const blob = new Blob( [workbook], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } ); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); return { filename, status, }; } function getFilterInfo(paginationInfo) { const dateInput = document.querySelector('input[placeholder="选择日期范围"]'); const nicknameInput = document.querySelector('input[placeholder="请输入用户昵称"]'); const filterSummary = [...document.querySelectorAll('p')] .map((p) => cleanText(p.textContent)) .find((text) => text.startsWith('您搜索的')) || ''; return { ...paginationInfo, dateRange: cleanText(dateInput?.value), nickname: cleanText(nicknameInput?.value), filterSummary, }; } async function exportAll() { if (exporting) { cancelRequested = true; updateButton('正在停止…', true); return; } exporting = true; cancelRequested = false; const rows = []; try { const paginationInfo = getPaginationInfo(); const info = getFilterInfo(paginationInfo); await returnToFirstPage((message) => { updateButton(message, true); }); for (let page = 1; page <= info.totalPages; page += 1) { if (cancelRequested) { throw new Error('__CANCELLED__'); } if (currentPage() !== page) { throw new Error( `页码异常:预期第${page}页,实际第${currentPage()}页。` ); } const pageRows = readPageRows(); if (!pageRows.length && info.expectedRows > 0) { throw new Error(`第${page}页没有读取到数据。`); } rows.push(...pageRows); updateButton( `停止导出 ${page}/${info.totalPages}`, true ); updateStatus( `已读取 ${rows.length} 条;请勿关闭或刷新页面` ); if (page < info.totalPages) { await moveOnePage('下一页', page + 1); } } updateButton('正在生成Excel…', true); const result = downloadXlsx(rows, info); updateStatus( `${result.filename}:${result.status}` ); if (rows.length !== info.expectedRows) { alert( `文件已经导出,但记录数需要复核。\n` + `页面显示:${info.expectedRows} 条\n` + `实际导出:${rows.length} 条\n` + `建议在直播结束、列表稳定后重新导出。` ); } } catch (error) { if (error.message === '__CANCELLED__') { updateStatus( `已停止,本次读取的 ${rows.length} 条记录未导出` ); } else { console.error('[B站礼物导出]', error); updateStatus( `导出失败:${error.message}` ); alert( `B站礼物清单导出失败:\n${error.message}` ); } } finally { exporting = false; cancelRequested = false; updateButton( '导出全部为Excel', false ); } } function updateButton(text, working) { const button = document.getElementById(BUTTON_ID); if (!button) { return; } button.textContent = text; button.style.background = working ? '#fb7299' : '#00aeec'; button.style.borderColor = working ? '#fb7299' : '#00aeec'; button.title = working ? '再次点击可停止导出' : '自动读取当前筛选条件下的全部分页'; } function updateStatus(text) { const status = document.getElementById(STATUS_ID); if (status) { status.textContent = text; } } function injectButton() { const panelId = 'bili-gift-export-panel'; const oldPanel = document.getElementById(panelId); // 只在“直播收益”礼物清单页面显示 if (!location.href.includes('/live-data/gift-list')) { if (oldPanel && !exporting) { oldPanel.remove(); } return; } if (oldPanel || !document.body) { return; } const panel = document.createElement('div'); panel.id = panelId; panel.style.cssText = [ 'position:fixed', 'right:28px', 'bottom:90px', 'z-index:2147483647', 'width:240px', 'padding:14px', 'box-sizing:border-box', 'background:#ffffff', 'border:1px solid #d9e1e8', 'border-radius:10px', 'box-shadow:0 5px 22px rgba(0,0,0,.18)', 'font-family:"Microsoft YaHei",sans-serif', ].join(';'); const title = document.createElement('div'); title.textContent = 'B站礼物清单导出'; title.style.cssText = [ 'margin-bottom:10px', 'color:#18191c', 'font-size:14px', 'font-weight:600', ].join(';'); const button = document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; button.textContent = '导出全部为Excel'; button.title = '自动读取当前筛选条件下的全部分页'; button.style.cssText = [ 'display:block', 'width:100%', 'height:38px', 'padding:0 12px', 'border:1px solid #00aeec', 'border-radius:6px', 'background:#00aeec', 'color:#ffffff', 'font-size:14px', 'cursor:pointer', ].join(';'); button.addEventListener('click', exportAll); const status = document.createElement('div'); status.id = STATUS_ID; status.textContent = '沿用页面当前筛选条件'; status.style.cssText = [ 'margin-top:9px', 'color:#6d757a', 'font-size:12px', 'line-height:1.6', 'word-break:break-all', ].join(';'); panel.append(title, button, status); document.body.appendChild(panel); console.log('[B站礼物导出] 悬浮按钮已经加载'); } const observer = new MutationObserver(() => { injectButton(); }); observer.observe(document.documentElement, { childList: true, subtree: true, }); window.addEventListener('hashchange', () => { setTimeout(injectButton, 300); }); // 防止B站局部刷新时MutationObserver没有捕捉到 setInterval(injectButton, 1000); console.log('[B站礼物导出] 油猴脚本已经启动'); injectButton(); })();