// ==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();
})();