B站粉丝快照工具
正在初始化……
等待读取账号。
比较名单明细(页面直接查看)
日志与限制说明
本工具只导出粉丝公开资料与关注时间,不导出登录凭据。
“关系已消失”不必然等于主动取关。
`;
document.body.appendChild(host);
const elements = {
host,
root,
summary: root.getElementById('summary'),
status: root.getElementById('status'),
comparisonDetails: root.getElementById('comparisonDetails'),
comparisonOutput: root.getElementById('comparisonOutput'),
log: root.getElementById('log'),
saveJson: root.getElementById('saveJson'),
saveCsv: root.getElementById('saveCsv'),
loadBaseline: root.getElementById('loadBaseline'),
loadBaselineLabel: root.getElementById('loadBaselineLabel'),
saveCompareJson: root.getElementById('saveCompareJson'),
saveCompareCsv: root.getElementById('saveCompareCsv'),
close: root.getElementById('close')
};
elements.close.addEventListener('click', () => host.remove());
elements.loadBaselineLabel.addEventListener('keydown', event => {
if (
(event.key === 'Enter' || event.key === ' ') &&
!elements.loadBaseline.disabled
) {
event.preventDefault();
elements.loadBaseline.click();
}
});
return elements;
}
const ui = createPanel();
function log(message, level = 'info') {
const line =
`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ` +
`${message}`;
console[
level === 'error'
? 'error'
: level === 'warn'
? 'warn'
: 'log'
](`[B站粉丝快照] ${message}`);
ui.log.textContent += `${line}\n`;
ui.log.scrollTop = ui.log.scrollHeight;
}
function setStatus(message, type = 'normal') {
ui.status.textContent = message;
ui.status.className =
type === 'error'
? 'status error'
: type === 'warning'
? 'status warning'
: type === 'success'
? 'status success'
: 'status';
}
function updateSummary() {
const login = state.login;
const report = state.report;
if (!login) {
ui.summary.textContent = '尚未识别登录账号。';
return;
}
const lines = [
`账号:${login.name || '未知昵称'}`,
`UID:${login.uid || '未知'}`,
`接口报告粉丝数:${
report?.finalReportedTotal ??
state.initialStat?.follower ??
'读取中'
}`,
`实际取得唯一 UID:${report?.exportedUniqueTotal ?? state.followers.length}`,
`历史观测停线:${CONFIG.historicalObservedDetailStop} 人(非固定上限)`,
`完整性:${
report
? report.complete
? '完整'
: '部分覆盖(可进行人数边界比较)'
: '读取中'
}`
];
if (report?.stopReason) {
lines.push(`停止原因:${report.stopReason}`);
}
ui.summary.textContent = lines.join('\n');
}
async function saveTextFile({
suggestedName,
text,
mimeType,
extension,
description
}) {
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName,
types: [
{
description,
accept: {
[mimeType]: [extension]
}
}
]
});
const writable = await handle.createWritable();
await writable.write(
new Blob([text], { type: `${mimeType};charset=utf-8` })
);
await writable.close();
return { method: 'showSaveFilePicker' };
} catch (error) {
if (error?.name === 'AbortError') {
throw error;
}
log(
`系统保存窗口不可用,改用浏览器下载:${error.message}`,
'warn'
);
}
}
const blob = new Blob([text], {
type: `${mimeType};charset=utf-8`
});
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = suggestedName;
anchor.rel = 'noopener';
anchor.style.display = 'none';
anchor.addEventListener(
'click',
event => event.stopPropagation(),
{ once: true }
);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
return { method: 'download-attribute' };
}
function followersToCsv(report) {
const headers = [
'UID',
'昵称',
'签名',
'头像地址',
'关注时间',
'关注时间戳',
'官方认证类型',
'官方认证说明',
'大会员类型',
'大会员状态'
];
const rows = report.followers.map((item) => [
item.uid,
item.name,
item.sign,
item.face,
item.followTime,
item.followTimestamp ?? '',
item.officialVerifyType ?? '',
item.officialVerifyDescription,
item.vipType ?? '',
item.vipStatus ?? ''
]);
return (
'\uFEFF' +
[headers, ...rows]
.map((row) => row.map(csvEscape).join(','))
.join('\r\n')
);
}
function comparisonToCsv(comparison) {
const headers = [
'变化类型',
'UID',
'旧快照昵称',
'当前昵称',
'旧快照关注时间',
'当前关注时间',
'旧快照记录序位',
'说明'
];
const previousPartial = comparison.previousComplete !== true;
const currentPartial = comparison.currentComplete !== true;
const removedRows = comparison.removed.map((item) => [
'关系已消失',
item.uid,
item.previousName,
'',
item.previousFollowTime,
'',
item.previousSnapshotOrderRank ?? '',
previousPartial
? '已由当前完整快照证实不再存在;范围仅限旧快照实际记录的账号'
: item.previousSnapshotOrderRank >
comparison.historicalObservedDetailStop
? `完整旧快照中的记录序位超过历史观测停线 ${comparison.historicalObservedDetailStop}`
: '可能是取关、注销、封禁、拉黑、平台清理或被移除'
]);
const addedRows = comparison.added.map((item) => [
previousPartial ? '旧采集后建立的当前关系' : '新增/重新建立关系',
item.uid,
'',
item.currentName,
'',
item.currentFollowTime,
'',
previousPartial
? '关注时间戳位于两次采集之间;包含重新关注的可能'
: currentPartial
? '当前已观测且完整旧快照中不存在;当前覆盖缺口不影响该新增身份结论'
: '当前快照中新增出现'
]);
const unclassifiedRows = (
comparison.currentOnlyUnclassified ?? []
).map((item) => [
'当前独有(未分类)',
item.uid,
'',
item.currentName,
'',
item.currentFollowTime,
'',
item.reason || '旧快照存在覆盖缺口,未标为新增'
]);
const previousUnclassifiedRows = (
comparison.previousOnlyUnclassified ?? []
).map((item) => [
'旧实录独有(未分类)',
item.uid,
item.previousName,
'',
item.previousFollowTime,
'',
item.previousSnapshotOrderRank ?? '',
item.reason || '当前快照存在覆盖缺口,未标为关系消失'
]);
const boundRows = comparison.comparisonIsExact
? []
: [[
'人数范围(仅数量)',
'',
'',
'',
'',
'',
'',
`关系消失 ${comparison.countBounds.removed.min}-` +
`${comparison.countBounds.removed.max};新增关系 ` +
`${comparison.countBounds.added.min}-` +
`${comparison.countBounds.added.max};净变化 ` +
`${comparison.countBounds.netChange}`
]];
return (
'\uFEFF' +
[
headers,
...removedRows,
...addedRows,
...previousUnclassifiedRows,
...unclassifiedRows,
...boundRows
]
.map((row) => row.map(csvEscape).join(','))
.join('\r\n')
);
}
function enableExportButtons() {
ui.saveJson.disabled = !state.report;
ui.saveCsv.disabled = !state.report;
const comparisonInputDisabled = !state.report;
ui.loadBaseline.disabled = comparisonInputDisabled;
ui.loadBaselineLabel.classList.toggle(
'disabled',
comparisonInputDisabled
);
ui.loadBaselineLabel.setAttribute(
'aria-disabled',
String(comparisonInputDisabled)
);
ui.saveCompareJson.disabled = !state.comparison;
ui.saveCompareCsv.disabled = !state.comparison;
}
ui.saveJson.addEventListener('click', async () => {
if (!state.report) return;
const filename =
`B站粉丝快照_${sanitizeFilename(state.report.targetName)}` +
`_UID${state.report.targetUid}_${timestampForFilename()}.json`;
try {
await saveTextFile({
suggestedName: filename,
text: JSON.stringify(state.report, null, 2),
mimeType: 'application/json',
extension: '.json',
description: 'B站粉丝快照 JSON'
});
setStatus(`JSON 已保存:${filename}`, 'success');
} catch (error) {
if (error?.name !== 'AbortError') {
setStatus(`JSON 保存失败:${error.message}`, 'error');
}
}
});
ui.saveCsv.addEventListener('click', async () => {
if (!state.report) return;
const filename =
`B站粉丝快照_${sanitizeFilename(state.report.targetName)}` +
`_UID${state.report.targetUid}_${timestampForFilename()}.csv`;
try {
await saveTextFile({
suggestedName: filename,
text: followersToCsv(state.report),
mimeType: 'text/csv',
extension: '.csv',
description: 'B站粉丝快照 CSV'
});
setStatus(`CSV 已保存:${filename}`, 'success');
} catch (error) {
if (error?.name !== 'AbortError') {
setStatus(`CSV 保存失败:${error.message}`, 'error');
}
}
});
ui.loadBaseline.addEventListener('change', async (event) => {
const input = event.currentTarget;
const file = input.files?.[0];
if (!file) return;
try {
state.comparison = null;
clearComparisonPresentation();
if (!state.report) {
throw new Error('当前快照尚未生成,请等待读取结束后再导入旧快照。');
}
const oldReport = JSON.parse(await file.text());
state.comparison = compareSnapshotReports(
oldReport,
state.report,
file.name
);
const comparison = state.comparison;
const bounds = comparison.countBounds;
if (comparison.comparisonMode === 'full') {
log(
`快照比较完成:关系已消失 ${comparison.removedCount},` +
`新增 ${comparison.addedCount}`
);
setStatus(
`精确比较完成:\n` +
`关系已消失候选 ${comparison.removedCount} 人\n` +
`新增或重新建立关系 ${comparison.addedCount} 人\n` +
`其中旧快照记录序位超过 ${CONFIG.historicalObservedDetailStop}:` +
`${comparison.removedBeyondHistoricalStopCount} 人`,
comparison.removedCount ? 'warning' : 'success'
);
} else if (
comparison.comparisonMode === 'old-partial-current-complete'
) {
log(
`受限比较完成:旧快照实录 ` +
`${comparison.previousExportedUniqueTotal} / ` +
`${comparison.previousReportedTotal},已证实关系消失 ` +
`${comparison.removedCount},消失人数范围 ` +
`${bounds.removed.min}-${bounds.removed.max},新增人数范围 ` +
`${bounds.added.min}-${bounds.added.max}`
);
setStatus(
`受限比较完成(旧快照部分、当前快照完整):\n` +
`已证实关系消失 ${comparison.removedCount} 人\n` +
`完整旧集合关系消失人数范围 ` +
`${bounds.removed.min}-${bounds.removed.max} 人\n` +
`当前新增关系人数范围 ` +
`${bounds.added.min}-${bounds.added.max} 人\n` +
`旧采集后建立的当前关系事件 ${comparison.addedCount} 人\n` +
`当前独有但因旧快照缺口未分类 ` +
`${comparison.currentOnlyUnclassifiedCount} 人\n` +
`旧快照覆盖缺口 ${comparison.previousCoverageGap} 人` +
(comparison.gapReconciliation.conditionalProjection
? '\n候选数与缺口相等,但时间字段语义尚未验证,结果保持受限模式'
: ''),
comparison.removedCount || comparison.currentOnlyUnclassifiedCount
? 'warning'
: 'success'
);
} else if (
comparison.comparisonMode === 'old-complete-current-partial'
) {
log(
`受限比较完成:当前快照实录 ` +
`${comparison.currentExportedUniqueTotal} / ` +
`${comparison.currentReportedTotal},确认新增 ` +
`${comparison.addedCount},消失人数范围 ` +
`${bounds.removed.min}-${bounds.removed.max}`
);
setStatus(
`受限比较完成(旧快照完整、当前快照部分):\n` +
`已确认新增或重新建立关系 ${comparison.addedCount} 人\n` +
`关系消失人数范围 ` +
`${bounds.removed.min}-${bounds.removed.max} 人\n` +
`新增关系人数范围 ` +
`${bounds.added.min}-${bounds.added.max} 人\n` +
`旧实录中因当前缺口未分类 ` +
`${comparison.previousOnlyUnclassifiedCount} 人\n` +
`当前快照覆盖缺口 ${comparison.currentCoverageGap} 人\n` +
`粉丝总数净变化 ${bounds.netChange >= 0 ? '+' : ''}` +
`${bounds.netChange}`,
'warning'
);
} else {
log(
`观测比较完成:两份快照均有覆盖缺口,交集 ` +
`${comparison.observedIntersectionCount},消失人数范围 ` +
`${bounds.removed.min}-${bounds.removed.max},新增人数范围 ` +
`${bounds.added.min}-${bounds.added.max}`
);
setStatus(
`观测比较完成(两份快照均为部分覆盖):\n` +
`已观测交集 ${comparison.observedIntersectionCount} 人\n` +
`关系消失人数范围 ` +
`${bounds.removed.min}-${bounds.removed.max} 人\n` +
`新增关系人数范围 ` +
`${bounds.added.min}-${bounds.added.max} 人\n` +
`旧侧未分类 ${comparison.previousOnlyUnclassifiedCount} 人;` +
`当前侧未分类 ${comparison.currentOnlyUnclassifiedCount} 人\n` +
`粉丝总数净变化 ${bounds.netChange >= 0 ? '+' : ''}` +
`${bounds.netChange}`,
'warning'
);
}
presentComparisonResult(comparison);
enableExportButtons();
} catch (error) {
state.comparison = null;
clearComparisonPresentation();
enableExportButtons();
setStatus(`快照比较已停止:${error.message}`, 'error');
log(`快照比较失败:${error.stack || error.message}`, 'error');
} finally {
input.value = '';
}
});
ui.saveCompareJson.addEventListener('click', async () => {
if (!state.comparison) return;
const filename =
`B站粉丝快照比较_${sanitizeFilename(state.comparison.targetName)}` +
`_UID${state.comparison.targetUid}_${timestampForFilename()}.json`;
try {
await saveTextFile({
suggestedName: filename,
text: JSON.stringify(state.comparison, null, 2),
mimeType: 'application/json',
extension: '.json',
description: 'B站粉丝快照比较 JSON'
});
setStatus(`比较 JSON 已保存:${filename}`, 'success');
} catch (error) {
if (error?.name !== 'AbortError') {
setStatus(`比较 JSON 保存失败:${error.message}`, 'error');
}
}
});
ui.saveCompareCsv.addEventListener('click', async () => {
if (!state.comparison) return;
const filename =
`B站粉丝快照比较_${sanitizeFilename(state.comparison.targetName)}` +
`_UID${state.comparison.targetUid}_${timestampForFilename()}.csv`;
try {
await saveTextFile({
suggestedName: filename,
text: comparisonToCsv(state.comparison),
mimeType: 'text/csv',
extension: '.csv',
description: 'B站粉丝快照比较 CSV'
});
setStatus(`比较 CSV 已保存:${filename}`, 'success');
} catch (error) {
if (error?.name !== 'AbortError') {
setStatus(`比较 CSV 保存失败:${error.message}`, 'error');
}
}
});
async function run() {
try {
setStatus('正在确认登录账号……');
state.login = await getLoginInfo();
if (!state.login.isLogin || !state.login.uid) {
throw new Error('当前浏览器未登录 B站。');
}
log(
`登录账号:${state.login.name},UID ${state.login.uid}`
);
updateSummary();
setStatus('正在读取粉丝总数……');
state.initialStat = await getRelationStat(state.login.uid);
const initialTotal = state.initialStat.follower;
log(`初始粉丝总数:${initialTotal}`);
updateSummary();
const followersMap = new Map();
let page = 1;
let noProgressPages = 0;
let listEndpointReportedTotal = null;
const expectedPages =
Number.isFinite(initialTotal) && initialTotal > 0
? Math.ceil(initialTotal / CONFIG.pageSize) + 2
: CONFIG.fallbackPageGuard;
while (page <= expectedPages) {
setStatus(
`正在读取第 ${page} 页……\n` +
`当前已取得 ${followersMap.size}` +
(
Number.isFinite(initialTotal)
? ` / 初始总数 ${initialTotal}`
: ''
)
);
const result = await getFollowerPage(
state.login.uid,
page
);
if (
Number.isFinite(result.total) &&
result.total >= 0
) {
listEndpointReportedTotal = result.total;
}
const before = followersMap.size;
for (const rawItem of result.list) {
const item = normalizeFollower(rawItem);
if (item && !followersMap.has(item.uid)) {
followersMap.set(item.uid, item);
}
}
const addedThisPage = followersMap.size - before;
log(
`第 ${page} 页:接口 ${result.endpoint},` +
`返回 ${result.list.length},新增唯一 UID ${addedThisPage},` +
`累计 ${followersMap.size}`
);
if (result.list.length === 0) {
state.stopReason = `第 ${page} 页返回空列表`;
break;
}
if (addedThisPage === 0) {
noProgressPages += 1;
} else {
noProgressPages = 0;
}
if (noProgressPages >= CONFIG.noProgressPageLimit) {
state.stopReason =
`连续 ${noProgressPages} 页没有新增 UID,接口可能开始重复数据`;
break;
}
const targetAtThisMoment = Math.max(
initialTotal || 0,
listEndpointReportedTotal || 0
);
if (
targetAtThisMoment > 0 &&
followersMap.size >= targetAtThisMoment
) {
state.stopReason = '已达到接口报告的粉丝总数';
break;
}
if (result.list.length < CONFIG.pageSize) {
state.stopReason =
`第 ${page} 页不足 ${CONFIG.pageSize} 条,已到接口末页`;
break;
}
page += 1;
await sleep(CONFIG.requestDelayMs);
}
if (!state.stopReason && page > expectedPages) {
state.stopReason =
`达到动态计算的安全页数 ${expectedPages}`;
}
state.followers = [...followersMap.values()].sort(
(a, b) =>
(b.followTimestamp || 0) -
(a.followTimestamp || 0)
);
try {
state.finalStat = await getRelationStat(state.login.uid);
} catch (error) {
state.warnings.push(
`结束时粉丝总数读取失败:${error.message}`
);
}
const finalStatReportedTotal = state.finalStat?.follower ?? null;
const finalReportedTotal =
finalStatReportedTotal ??
listEndpointReportedTotal ??
initialTotal ??
null;
const completeness = evaluateSnapshotCompleteness({
initialReportedTotal: initialTotal,
listEndpointReportedTotal,
finalReportedTotal: finalStatReportedTotal,
exportedUniqueTotal: state.followers.length
});
const { complete, reportedTotalsConsistent } = completeness;
const serviceDetailLimitLikelyReached = matchesHistoricalDetailStop({
reportedTotal: finalReportedTotal,
exportedUniqueTotal: state.followers.length,
historicalObservedDetailStop: CONFIG.historicalObservedDetailStop
});
if (!reportedTotalsConsistent) {
state.warnings.push(
'扫描前总数、名单接口总数和扫描后总数不一致,因此本次快照不具备比较资格。'
);
}
if (serviceDetailLimitLikelyReached) {
state.warnings.push(
`接口报告共有 ${finalReportedTotal} 名粉丝,但本次明细恰好停在历史曾观测到的 ${CONFIG.historicalObservedDetailStop} 人位置。该数值不是固定上限;本次快照仍不完整,未返回的账号不会进入比较。`
);
} else if (!complete) {
state.warnings.push(
'实际取得人数少于接口报告总数。可能原因包括服务端展示上限、风控、权限限制、接口变化或导出期间关系发生变化。'
);
}
state.report = {
reportType: 'bilibili-current-follower-snapshot',
reportVersion: 'public-2026-08-22-v1.7',
generatedAt: new Date().toISOString(),
generatedAtLocal: new Date().toString(),
targetUid: state.login.uid,
targetName: state.login.name,
initialReportedTotal: initialTotal,
listEndpointReportedTotal,
finalReportedTotal,
historicalObservedDetailStop:
CONFIG.historicalObservedDetailStop,
historicalObservedDetailStopIsFixedLimit: false,
serviceDetailLimitLikelyReached,
exportedUniqueTotal: state.followers.length,
reportedTotalsConsistent,
complete,
pageSize: CONFIG.pageSize,
endpointUsed: state.selectedEndpoint?.name ?? null,
endpointsUsed: [...state.endpointsUsed],
stopReason: state.stopReason,
durationMs:
Date.now() - state.startedAt.getTime(),
warnings: state.warnings,
errors: state.errors,
privacy:
'不包含 Cookie、SESSDATA、bili_jct、密码或验证码',
interpretation:
'该文件是生成时刻的当前粉丝快照,不是历史取关日志。',
followers: state.followers,
requestLog: state.requestLog
};
state.running = false;
window.__BILI_FOLLOWER_SNAPSHOT__ = state.report;
updateSummary();
enableExportButtons();
if (complete) {
setStatus(
`读取完成:${state.followers.length} 人。\n` +
'结果通过总数完整性校验,可以保存。',
'success'
);
} else if (serviceDetailLimitLikelyReached) {
setStatus(
`读取结束:接口总数 ${finalReportedTotal},` +
`仅取得前 ${state.followers.length} 人。\n` +
`本次恰好停在历史观测值 ${CONFIG.historicalObservedDetailStop} 人;` +
'该值并非固定上限。本次未返回的账号明细未包含在快照中,' +
'仍可导入旧快照生成已确认身份与严格人数边界。',
'warning'
);
} else {
setStatus(
`读取结束,但结果不完整:${state.followers.length}` +
` / ${finalReportedTotal ?? '未知总数'}。\n` +
'仍可保存并导入旧快照;比较结果会明确标为受限或仅观测。',
'warning'
);
}
log(
`完成:实际 ${state.followers.length},` +
`报告总数 ${finalReportedTotal},完整=${complete}`
);
} catch (error) {
state.running = false;
state.errors.push(error.message);
updateSummary();
enableExportButtons();
setStatus(`运行失败:${error.message}`, 'error');
log(error.stack || error.message, 'error');
}
}
await run();
})();