/** * Bilibili 当前粉丝完整导出与快照比较工具 * 通用公开版 * * 功能: * 1. 自动识别当前登录的 B站账号和 UID。 * 2. 动态读取粉丝总数并自动分页,不写死 UID、人数或页数。 * 3. 导出 JSON 与 CSV。 * 4. 导入之前导出的 JSON,计算新增粉丝和“关系已消失”候选。 * 5. 不导出 Cookie、SESSDATA、bili_jct、密码或验证码。 * * 重要限制: * - 接口分页边界会变化。2026-08-08 曾观测到明细恰好停在 1000, * 2026-08-09 又实测完整返回 1069;因此 1000 仅作为历史停线诊断值, * 不是固定上限。 * - 两份快照都完整时输出精确双向差集;任意一侧存在覆盖缺口时,仍输出 * 已确认身份、已观测交集、完整集合的严格人数范围和精确净数量变化。 * 覆盖缺口中的身份保持未分类,不自动补齐名单。 * - “关系已消失”可能包括主动取关、注销、封禁、拉黑、平台清理或被移除, * 不能一律认定为主动取关。 * * 运行位置: * - 登录 B站后,在 https://space.bilibili.com/ 页面打开开发者工具 Console。 * - 粘贴完整脚本并运行。 */ (async () => { 'use strict'; const CONFIG = Object.freeze({ pageSize: 50, requestDelayMs: 350, maxRetries: 3, retryBaseDelayMs: 900, noProgressPageLimit: 2, fallbackPageGuard: 10000, historicalObservedDetailStop: 1000, gapCandidateClockSkewMs: 5000, endpointCandidates: [ { name: 'x/relation/fans', buildUrl(uid, page, pageSize) { const params = new URLSearchParams({ vmid: String(uid), pn: String(page), ps: String(pageSize), order: 'desc' }); return `https://api.bilibili.com/x/relation/fans?${params}`; } }, { name: 'x/relation/followers', buildUrl(uid, page, pageSize) { const params = new URLSearchParams({ vmid: String(uid), pn: String(page), ps: String(pageSize), order: 'desc' }); return `https://api.bilibili.com/x/relation/followers?${params}`; } } ] }); const TOOL_ID = '__bili_follower_snapshot_tool__'; document.getElementById(TOOL_ID)?.remove(); const state = { startedAt: new Date(), login: null, initialStat: null, finalStat: null, selectedEndpoint: null, endpointsUsed: new Set(), followers: [], report: null, comparison: null, requestLog: [], warnings: [], errors: [], stopReason: '', running: true }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function safeString(value) { return value == null ? '' : String(value); } function escapeHtml(value) { return safeString(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function csvEscape(value) { const text = safeString(value); return `"${text.replaceAll('"', '""')}"`; } function normalizeUid(value) { const uid = Number(value); return Number.isSafeInteger(uid) && uid > 0 ? uid : null; } function formatLocalTime(timestampSeconds) { const timestamp = Number(timestampSeconds); if (!Number.isFinite(timestamp) || timestamp <= 0) return ''; return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }); } function sanitizeFilename(value) { return safeString(value) .replace(/[\\/:*?"<>|]+/g, '_') .replace(/\s+/g, ' ') .trim(); } function timestampForFilename() { return new Date() .toISOString() .replaceAll(':', '-') .replace(/\.\d{3}Z$/, 'Z'); } function uniqueByUid(items) { const map = new Map(); for (const item of items) { const uid = normalizeUid(item?.uid ?? item?.mid); if (uid && !map.has(uid)) { map.set(uid, { ...item, uid }); } } return [...map.values()]; } function normalizeFollower(item) { const uid = normalizeUid(item?.mid ?? item?.uid); if (!uid) return null; const followTimestamp = Number(item?.mtime || item?.followTimestamp || 0) || null; return { uid, name: safeString(item?.uname ?? item?.name), sign: safeString(item?.sign), face: safeString(item?.face), followTimestamp, followTime: followTimestamp ? formatLocalTime(followTimestamp) : '', followTimeIso: followTimestamp ? new Date(followTimestamp * 1000).toISOString() : '', attribute: item?.attribute ?? null, officialVerifyType: item?.official_verify?.type ?? item?.officialVerifyType ?? null, officialVerifyDescription: safeString( item?.official_verify?.desc ?? item?.officialVerifyDescription ), vipType: item?.vip?.vipType ?? item?.vipType ?? null, vipStatus: item?.vip?.vipStatus ?? item?.vipStatus ?? null }; } function evaluateSnapshotCompleteness({ initialReportedTotal, listEndpointReportedTotal, finalReportedTotal, exportedUniqueTotal }) { const reportedTotals = [ initialReportedTotal, listEndpointReportedTotal, finalReportedTotal ]; const reportedTotalsConsistent = reportedTotals.every( (value) => Number.isSafeInteger(value) && value >= 0 ) && reportedTotals.every((value) => value === reportedTotals[0]); const exportedCountValid = Number.isSafeInteger(exportedUniqueTotal) && exportedUniqueTotal >= 0; return { reportedTotalsConsistent, complete: reportedTotalsConsistent && exportedCountValid && exportedUniqueTotal === finalReportedTotal }; } function matchesHistoricalDetailStop({ reportedTotal, exportedUniqueTotal, historicalObservedDetailStop }) { return ( Number.isSafeInteger(reportedTotal) && Number.isSafeInteger(exportedUniqueTotal) && Number.isSafeInteger(historicalObservedDetailStop) && historicalObservedDetailStop > 0 && reportedTotal > historicalObservedDetailStop && exportedUniqueTotal === historicalObservedDetailStop ); } function validateSnapshotForComparison( report, label, { requireComplete = true } = {} ) { if (!report || typeof report !== 'object' || Array.isArray(report)) { throw new Error(`${label}快照不是有效的 JSON 对象。`); } const sourceField = Array.isArray(report.followers) ? 'followers' : Array.isArray(report.currentFollowers) ? 'currentFollowers' : null; if (!sourceField) { throw new Error(`${label}快照中没有 followers 数组。`); } const targetUid = normalizeUid(report.targetUid); if (!targetUid) { throw new Error(`${label}快照缺少有效的 targetUid。`); } const rawList = report[sourceField]; const normalizedItems = rawList.map(normalizeFollower); if (normalizedItems.some((item) => !item)) { throw new Error(`${label}快照包含无效的粉丝 UID。`); } const followers = uniqueByUid(normalizedItems); if (followers.length !== rawList.length) { throw new Error( `${label}快照的粉丝 UID 存在重复:数组 ${rawList.length} 项,` + `唯一 UID ${followers.length} 个。` ); } const observedCountField = sourceField === 'currentFollowers' ? 'currentFollowerCount' : 'exportedUniqueTotal'; const rawObservedCount = report[observedCountField]; const observedCount = rawObservedCount == null ? followers.length : rawObservedCount; const reportedTotal = report.finalReportedTotal; if (!Number.isSafeInteger(observedCount) || observedCount < 0) { throw new Error(`${label}快照缺少有效的 ${observedCountField}。`); } if (observedCount !== followers.length) { throw new Error( `${label}快照计数不一致:${observedCountField}=${observedCount},` + `唯一 UID=${followers.length}。` ); } if (!Number.isSafeInteger(reportedTotal) || reportedTotal < 0) { throw new Error(`${label}快照缺少有效的 finalReportedTotal。`); } const hasCompleteField = Object.prototype.hasOwnProperty.call( report, 'complete' ); if (hasCompleteField && typeof report.complete !== 'boolean') { throw new Error(`${label}快照的 complete 不是布尔值。`); } const complete = hasCompleteField ? report.complete : reportedTotal === followers.length; const completenessSource = hasCompleteField ? 'declared' : 'inferred-from-exact-observed-and-reported-counts'; if (requireComplete && complete !== true) { throw new Error( `${label}快照不完整(实际 ${observedCount} / 报告 ${reportedTotal}),` + '已停止精确比较,避免把未返回的粉丝误判为关系消失。' ); } if (complete === true && reportedTotal !== followers.length) { throw new Error( `${label}快照计数不一致:finalReportedTotal=${reportedTotal},` + `唯一 UID=${followers.length}。` ); } if (complete === false && reportedTotal < followers.length) { throw new Error( `${label}快照报告总数小于唯一 UID 数:` + `${reportedTotal} < ${followers.length}。` ); } const optionalCountFields = [ 'initialReportedTotal', 'listEndpointReportedTotal', 'reportedTotalForCoverage' ]; for (const field of optionalCountFields) { if (report[field] == null) continue; const value = report[field]; if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`${label}快照的 ${field} 不是有效计数。`); } if (value !== reportedTotal) { throw new Error( `${label}快照计数不一致:${field}=${value},` + `finalReportedTotal=${reportedTotal}。` ); } } if ( Object.prototype.hasOwnProperty.call( report, 'listEndpointReportedTotals' ) ) { const values = report.listEndpointReportedTotals; if (!Array.isArray(values) || values.length === 0) { throw new Error( `${label}快照的 listEndpointReportedTotals 不是非空数组。` ); } for (const [index, value] of values.entries()) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error( `${label}快照的 listEndpointReportedTotals[${index}] ` + '不是有效计数。' ); } if (value !== reportedTotal) { throw new Error( `${label}快照计数不一致:` + `listEndpointReportedTotals[${index}]=${value},` + `finalReportedTotal=${reportedTotal}。` ); } } } if (report.integrity != null) { if ( typeof report.integrity !== 'object' || Array.isArray(report.integrity) ) { throw new Error(`${label}快照的 integrity 不是有效对象。`); } const integrityCountFields = [ 'unifiedReportedTotal' ]; for (const field of integrityCountFields) { if (report.integrity[field] == null) continue; const value = report.integrity[field]; if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`${label}快照的 integrity.${field} 不是有效计数。`); } if (value !== reportedTotal) { throw new Error( `${label}快照计数不一致:integrity.${field}=${value},` + `finalReportedTotal=${reportedTotal}。` ); } } const stableTrueWhenPresent = [ 'scanWindowCountStable', 'listEndpointTotalsStable', 'listTotalsAgreeWithStat' ]; for (const field of stableTrueWhenPresent) { if ( Object.prototype.hasOwnProperty.call(report.integrity, field) && report.integrity[field] !== true ) { throw new Error( `${label}快照的 integrity.${field} 未通过完整性校验。` ); } } if (complete === true) { for (const field of ['exactUniqueTotal', 'uniqueCoverage']) { if ( Object.prototype.hasOwnProperty.call(report.integrity, field) && report.integrity[field] !== true ) { throw new Error( `${label}快照的 integrity.${field} 未通过完整性校验。` ); } } } else if (reportedTotal > followers.length) { for (const field of ['exactUniqueTotal', 'uniqueCoverage']) { if ( Object.prototype.hasOwnProperty.call(report.integrity, field) && report.integrity[field] !== false ) { throw new Error( `${label}快照的 integrity.${field} 与覆盖缺口矛盾。` ); } } if ( Object.prototype.hasOwnProperty.call( report.integrity, 'underCoverage' ) && report.integrity.underCoverage !== true ) { throw new Error( `${label}快照的 integrity.underCoverage 与覆盖缺口矛盾。` ); } } if (report.integrity.overCoverage === true) { throw new Error(`${label}快照声明存在 overCoverage。`); } if (complete === true && report.integrity.underCoverage === true) { throw new Error(`${label}快照声明存在 underCoverage。`); } } return { targetUid, followers, complete, completenessSource, reportedTotal, exportedUniqueTotal: observedCount, observedCountSource: rawObservedCount == null ? 'unique-list-length' : observedCountField }; } function comparisonModeForSnapshots(previousComplete, currentComplete) { if (previousComplete && currentComplete) return 'full'; if (!previousComplete && currentComplete) { return 'old-partial-current-complete'; } if (previousComplete && !currentComplete) { return 'old-complete-current-partial'; } return 'both-partial'; } function computeComparisonBounds({ previousReportedTotal, currentReportedTotal, previousObservedCount, currentObservedCount, observedIntersectionCount, knownRemovedIdentityCount }) { const counts = [ previousReportedTotal, currentReportedTotal, previousObservedCount, currentObservedCount, observedIntersectionCount, knownRemovedIdentityCount ]; if (counts.some((value) => !Number.isSafeInteger(value) || value < 0)) { throw new Error('比较计数不是有效的非负安全整数。'); } if ( previousObservedCount > previousReportedTotal || currentObservedCount > currentReportedTotal || observedIntersectionCount > previousObservedCount || observedIntersectionCount > currentObservedCount ) { throw new Error('比较计数之间存在覆盖矛盾。'); } const previousCoverageGap = previousReportedTotal - previousObservedCount; const currentCoverageGap = currentReportedTotal - currentObservedCount; const previousObservedOnlyCount = previousObservedCount - observedIntersectionCount; const currentObservedOnlyCount = currentObservedCount - observedIntersectionCount; const removedMin = Math.max( 0, previousReportedTotal - currentReportedTotal, previousObservedOnlyCount - currentCoverageGap ); const removedMax = previousReportedTotal - observedIntersectionCount; const addedMin = Math.max( 0, currentReportedTotal - previousReportedTotal, currentObservedOnlyCount - previousCoverageGap ); const addedMax = currentReportedTotal - observedIntersectionCount; const oldGapSurvivorMax = Math.min( previousCoverageGap, currentObservedOnlyCount + currentCoverageGap ); if ( removedMin > removedMax || addedMin > addedMax || addedMin - removedMin !== currentReportedTotal - previousReportedTotal || addedMax - removedMax !== currentReportedTotal - previousReportedTotal ) { throw new Error('比较人数边界未通过净变化恒等式校验。'); } return { previousCoverageGap, currentCoverageGap, previousObservedOnlyCount, currentObservedOnlyCount, observedIntersectionCount, oldGapSurvivors: { min: 0, max: oldGapSurvivorMax, exact: oldGapSurvivorMax === 0 }, removed: { knownIdentityCount: knownRemovedIdentityCount, min: removedMin, max: removedMax, exact: removedMin === removedMax }, added: { min: addedMin, max: addedMax, exact: addedMin === addedMax }, netChange: currentReportedTotal - previousReportedTotal }; } function compareSnapshotReports( previousReport, currentReport, previousFileName = '' ) { const previousSnapshot = validateSnapshotForComparison( previousReport, '旧', { requireComplete: false } ); const currentSnapshot = validateSnapshotForComparison( currentReport, '当前', { requireComplete: false } ); if (previousSnapshot.targetUid !== currentSnapshot.targetUid) { throw new Error( `快照账号不一致:旧快照 UID ${previousSnapshot.targetUid},` + `当前快照 UID ${currentSnapshot.targetUid}。` ); } const previousGeneratedAtMs = Date.parse(previousReport.generatedAt); const currentGeneratedAtMs = Date.parse(currentReport.generatedAt); if (!Number.isFinite(previousGeneratedAtMs)) { throw new Error('旧快照 generatedAt 不是可解析的采集时间。'); } if (!Number.isFinite(currentGeneratedAtMs)) { throw new Error('当前快照 generatedAt 不是可解析的采集时间。'); } if (previousGeneratedAtMs > currentGeneratedAtMs) { throw new Error('旧快照采集时间晚于当前快照,请按时间先后顺序导入。'); } const comparisonMode = comparisonModeForSnapshots( previousSnapshot.complete, currentSnapshot.complete ); const previousDurationMs = Number(previousReport.durationMs); const previousWindowStartMs = Number.isFinite(previousDurationMs) && previousDurationMs >= 0 ? previousGeneratedAtMs - previousDurationMs : null; const gapCandidateCutoffMs = previousWindowStartMs === null ? null : previousWindowStartMs - CONFIG.gapCandidateClockSkewMs; const previousMap = new Map( previousSnapshot.followers.map((item) => [item.uid, item]) ); const previousRankMap = new Map( previousSnapshot.followers.map((item, index) => [item.uid, index + 1]) ); const currentMap = new Map( currentSnapshot.followers.map((item) => [item.uid, item]) ); const observedIntersection = []; const previousObservedOnly = []; const currentObservedOnly = []; for (const [uid, item] of previousMap) { if (currentMap.has(uid)) { const currentItem = currentMap.get(uid); observedIntersection.push({ uid, previousName: item.name, currentName: currentItem.name, previousFollowTime: item.followTime, currentFollowTime: currentItem.followTime, previousSnapshotOrderRank: previousRankMap.get(uid) }); } else { previousObservedOnly.push({ uid, previousName: item.name, previousFollowTime: item.followTime, previousFollowTimestamp: item.followTimestamp, previousSnapshotOrderRank: previousRankMap.get(uid) }); } } for (const [uid, item] of currentMap) { if (!previousMap.has(uid)) { currentObservedOnly.push({ uid, currentName: item.name, currentFollowTime: item.followTime, currentFollowTimestamp: item.followTimestamp }); } } const removed = currentSnapshot.complete ? previousObservedOnly.map((item) => ({ ...item, confidence: 'high', evidence: 'previous-observed-and-absent-from-current-complete' })) : []; const previousOnlyUnclassified = currentSnapshot.complete ? [] : previousObservedOnly.map((item) => ({ ...item, confidence: 'unclassified', reason: '当前快照存在覆盖缺口;该 UID 可能位于当前未返回名单,未标为关系消失' })); const preexistingGapCandidates = !previousSnapshot.complete && gapCandidateCutoffMs !== null ? currentObservedOnly.filter((item) => Number.isSafeInteger(item.currentFollowTimestamp) && item.currentFollowTimestamp > 0 && (item.currentFollowTimestamp + 1) * 1000 <= gapCandidateCutoffMs ) : []; const preexistingGapCandidateUids = new Set( preexistingGapCandidates.map((item) => item.uid) ); const added = []; const currentOnlyUnclassified = []; for (const currentOnlyItem of currentObservedOnly) { if (previousSnapshot.complete) { added.push({ ...currentOnlyItem, confidence: 'high', evidence: 'current-observed-and-absent-from-previous-complete' }); } else if ( Number.isSafeInteger(currentOnlyItem.currentFollowTimestamp) && currentOnlyItem.currentFollowTimestamp > 0 && currentOnlyItem.currentFollowTimestamp * 1000 > previousGeneratedAtMs && currentOnlyItem.currentFollowTimestamp * 1000 <= currentGeneratedAtMs ) { added.push({ ...currentOnlyItem, confidence: 'high', evidence: 'current-relationship-event-after-previous-capture' }); } else { const timestampIsSafeUnixSeconds = Number.isSafeInteger(currentOnlyItem.currentFollowTimestamp) && currentOnlyItem.currentFollowTimestamp > 0; const timestampAfterCurrent = timestampIsSafeUnixSeconds && currentOnlyItem.currentFollowTimestamp * 1000 > currentGeneratedAtMs; const preexistingGapCandidate = preexistingGapCandidateUids.has( currentOnlyItem.uid ); currentOnlyUnclassified.push({ ...currentOnlyItem, confidence: 'unclassified', preexistingGapCandidate, reason: !timestampIsSafeUnixSeconds ? '关注时间戳不是正安全整数 Unix 秒,未作为新增证据' : timestampAfterCurrent ? '关注时间戳晚于当前快照采集时间,未作为新增证据' : preexistingGapCandidate ? '时间早于旧扫描窗口且位于覆盖缺口候选中;时间字段语义尚未验证,未自动补齐旧名单' : '时间证据不足,可能来自旧快照覆盖缺口或旧扫描窗口内的关系变化' }); } } const bounds = computeComparisonBounds({ previousReportedTotal: previousSnapshot.reportedTotal, currentReportedTotal: currentSnapshot.reportedTotal, previousObservedCount: previousSnapshot.exportedUniqueTotal, currentObservedCount: currentSnapshot.exportedUniqueTotal, observedIntersectionCount: observedIntersection.length, knownRemovedIdentityCount: removed.length }); const countBounds = { oldGapSurvivors: bounds.oldGapSurvivors, removed: bounds.removed, added: bounds.added, netChange: bounds.netChange }; const previousCoverageGap = bounds.previousCoverageGap; const currentCoverageGap = bounds.currentCoverageGap; const removedBeyondHistoricalStopCount = removed.filter( (item) => item.previousSnapshotOrderRank > CONFIG.historicalObservedDetailStop ).length; const gapCandidateCountMatches = comparisonMode === 'old-partial-current-complete' && previousCoverageGap > 0 && preexistingGapCandidates.length === previousCoverageGap; const gapReconciliationStatus = previousSnapshot.complete ? 'not-applicable' : !currentSnapshot.complete ? 'current-coverage-incomplete' : gapCandidateCutoffMs === null ? 'insufficient-time-boundary' : preexistingGapCandidates.length > previousCoverageGap ? 'candidate-count-exceeds-gap' : gapCandidateCountMatches ? 'candidate-count-matches-gap-unverified-timestamp-semantics' : 'candidate-count-does-not-match-gap'; const conditionalProjection = gapCandidateCountMatches ? { applied: false, assumption: 'all-gap-candidates-are-continuous-survivors-from-the-previous-gap', oldGapSurvivorCount: preexistingGapCandidates.length, removedCount: removed.length, addedCount: currentObservedOnly.length - preexistingGapCandidates.length } : null; const comparisonEvidenceLevel = comparisonMode === 'full' ? 'exact' : comparisonMode === 'both-partial' ? 'observed' : 'bounded'; return { reportType: 'bilibili-follower-snapshot-comparison', reportVersion: 'public-comparison-2026-08-17-v1.4', generatedAt: new Date().toISOString(), comparisonMode, comparisonEvidenceLevel, comparisonIsExact: comparisonMode === 'full', oldGapReconciled: false, targetUid: currentReport.targetUid, targetName: currentReport.targetName, previousFileName, previousGeneratedAt: previousReport.generatedAt ?? null, currentGeneratedAt: currentReport.generatedAt, previousComplete: previousSnapshot.complete, currentComplete: currentSnapshot.complete, previousCompletenessSource: previousSnapshot.completenessSource, currentCompletenessSource: currentSnapshot.completenessSource, previousReportedTotal: previousSnapshot.reportedTotal, currentReportedTotal: currentSnapshot.reportedTotal, previousExportedUniqueTotal: previousSnapshot.exportedUniqueTotal, currentExportedUniqueTotal: currentSnapshot.exportedUniqueTotal, previousFollowerCount: previousSnapshot.followers.length, currentFollowerCount: currentSnapshot.followers.length, historicalObservedDetailStop: CONFIG.historicalObservedDetailStop, oldCoverageGap: previousCoverageGap, previousCoverageGap, currentCoverageGap, observedIntersectionCount: observedIntersection.length, previousObservedOnlyCount: previousObservedOnly.length, currentOnlyCount: currentObservedOnly.length, previousOnlyUnclassifiedCount: previousOnlyUnclassified.length, currentOnlyUnclassifiedCount: currentOnlyUnclassified.length, preexistingGapCandidateCount: preexistingGapCandidates.length, removedCount: removed.length, removedBeyondHistoricalStopCount, addedCount: added.length, addedMeaning: previousSnapshot.complete ? 'current-observed-and-absent-from-previous-complete-may-include-refollow' : 'current-relationship-event-after-previous-capture-may-include-refollow', countBounds, gapReconciliation: { status: gapReconciliationStatus, exactApplied: false, timestampSemanticsValidated: false, candidateBoundary: { previousWindowStart: previousWindowStartMs === null ? null : new Date(previousWindowStartMs).toISOString(), clockSkewAllowanceMs: CONFIG.gapCandidateClockSkewMs, candidateCutoff: gapCandidateCutoffMs === null ? null : new Date(gapCandidateCutoffMs).toISOString(), unixSecondPrecisionGuardMs: 1000 }, candidateCount: preexistingGapCandidates.length, oldCoverageGap: previousCoverageGap, conditionalProjection }, confidence: comparisonMode === 'full' ? 'high' : comparisonMode === 'old-partial-current-complete' ? 'directional-high' : comparisonMode === 'both-partial' ? 'observational-bounded' : 'bounded-high', directionalEvidence: { removed: { confidence: currentSnapshot.complete ? 'high' : 'unclassified', rule: currentSnapshot.complete ? 'previous-observed-and-absent-from-current-complete' : 'previous-observed-but-current-coverage-incomplete' }, added: { confidence: previousSnapshot.complete ? 'high' : 'timestamp-bounded-high', rule: previousSnapshot.complete ? 'current-observed-and-absent-from-previous-complete' : 'positive-safe-integer-current-relationship-event-after-previous-and-not-after-current' } }, coverage: { previous: { complete: previousSnapshot.complete, observedCount: previousSnapshot.exportedUniqueTotal, reportedTotal: previousSnapshot.reportedTotal, gap: previousCoverageGap, gapCandidateCount: preexistingGapCandidates.length, gapReconciled: false }, current: { complete: currentSnapshot.complete, observedCount: currentSnapshot.exportedUniqueTotal, reportedTotal: currentSnapshot.reportedTotal, gap: currentCoverageGap }, removedScope: currentSnapshot.complete ? previousSnapshot.complete ? 'all-previous-followers' : 'previous-observed-followers-only' : 'no-identity-removal-claim-current-coverage-incomplete', addedRule: previousSnapshot.complete ? 'current-observed-not-in-complete-previous' : 'current-relationship-event-after-previous-capture', totalCountRule: comparisonMode === 'full' ? 'exact-set-difference' : 'strict-cardinality-bounds-over-unobserved-coverage-gaps' }, validity: { valid: true, rule: comparisonMode === 'full' ? 'both-complete-same-target-exact-counts' : comparisonMode === 'old-partial-current-complete' ? 'old-observed-minus-current-complete-and-timestamp-bounded-additions' : comparisonMode === 'old-complete-current-partial' ? 'current-observed-minus-previous-complete-and-cardinality-bounds' : 'observed-intersection-and-cardinality-bounds-over-both-gaps', checks: { bothComplete: previousSnapshot.complete && currentSnapshot.complete, currentComplete: currentSnapshot.complete, previousComplete: previousSnapshot.complete, sameTargetUid: previousSnapshot.targetUid === currentSnapshot.targetUid, previousObservedCountMatchesList: previousSnapshot.followers.length === previousSnapshot.exportedUniqueTotal, currentObservedCountMatchesList: currentSnapshot.followers.length === currentSnapshot.exportedUniqueTotal, reportedTotalsNotLessThanObserved: previousSnapshot.reportedTotal >= previousSnapshot.exportedUniqueTotal && currentSnapshot.reportedTotal >= currentSnapshot.exportedUniqueTotal, previousCaptureTimeValid: true, currentCaptureTimeValid: true, captureOrderValid: previousGeneratedAtMs <= currentGeneratedAtMs, countBoundsPreserveNetChange: countBounds.added.min - countBounds.removed.min === countBounds.netChange && countBounds.added.max - countBounds.removed.max === countBounds.netChange, timestampSemanticsValidatedForGapIdentity: false } }, interpretation: comparisonMode === 'full' ? 'removed 表示关系已消失候选,可能包括主动取关、注销、封禁、拉黑、平台清理或被移除。' : 'countBounds 给出完整集合的严格人数范围;身份数组只包含由完整对侧快照或时间边界支持的结果,覆盖缺口中的身份保持未分类。', removed, added, confirmedSurvivors: observedIntersection, previousOnlyUnclassified, preexistingGapCandidates, currentOnlyUnclassified }; } function comparisonIdentityName(item) { return ( safeString(item?.previousName ?? item?.currentName ?? item?.name).trim() || '未知昵称' ); } function comparisonIdentityLine(item) { const details = [`UID ${item.uid}`]; if (Number.isSafeInteger(item.previousSnapshotOrderRank)) { details.push(`旧序位 ${item.previousSnapshotOrderRank}`); } const followTime = safeString( item.previousFollowTime ?? item.currentFollowTime ?? item.followTime ).trim(); if (followTime) details.push(`关注时间 ${followTime}`); return `- ${comparisonIdentityName(item)}(${details.join(';')})`; } function comparisonVisibleSections(comparison) { const sections = [ { key: 'removed', title: '已确认当前关系消失候选', items: Array.isArray(comparison?.removed) ? comparison.removed : [] }, { key: 'added', title: '新增或重新建立关系', items: Array.isArray(comparison?.added) ? comparison.added : [] } ]; const optionalSections = [ { key: 'previousOnlyUnclassified', title: '当前覆盖缺口未分类(不是关系消失结论)', items: Array.isArray(comparison?.previousOnlyUnclassified) ? comparison.previousOnlyUnclassified : [] }, { key: 'currentOnlyUnclassified', title: '旧覆盖缺口未分类(不是新增结论)', items: Array.isArray(comparison?.currentOnlyUnclassified) ? comparison.currentOnlyUnclassified : [] } ]; return [ ...sections, ...optionalSections.filter((section) => section.items.length > 0) ]; } function comparisonVisibleText(comparison) { return comparisonVisibleSections(comparison) .map((section) => `${section.title}(${section.items.length})\n` + (section.items.length ? section.items.map(comparisonIdentityLine).join('\n') : '- 无') ) .join('\n\n'); } function comparisonConsoleRows(items) { return items.map((item) => ({ 昵称: comparisonIdentityName(item), UID: item.uid, 旧序位: Number.isSafeInteger(item.previousSnapshotOrderRank) ? item.previousSnapshotOrderRank : '', 关注时间: safeString( item.previousFollowTime ?? item.currentFollowTime ?? item.followTime ), 证据: safeString(item.evidence ?? item.reason) })); } function clearComparisonPresentation() { ui.comparisonDetails.hidden = true; ui.comparisonDetails.open = false; ui.comparisonOutput.textContent = ''; } function presentComparisonResult(comparison) { const sections = comparisonVisibleSections(comparison); ui.comparisonOutput.textContent = comparisonVisibleText(comparison); ui.comparisonDetails.hidden = false; ui.comparisonDetails.open = true; console.group( `[B站粉丝快照] 比较名单明细(${comparison.comparisonMode})` ); for (const section of sections) { const writer = section.key === 'removed' ? console.warn : console.log; writer.call(console, `${section.title}:${section.items.length} 人`); if (section.items.length) { console.table(comparisonConsoleRows(section.items)); } } console.groupEnd(); } async function fetchJson(url, label, attempt = 1) { const started = performance.now(); try { const response = await fetch(url, { method: 'GET', credentials: 'include', cache: 'no-store', headers: { Accept: 'application/json, text/plain, */*' } }); const text = await response.text(); let json; try { json = JSON.parse(text); } catch { throw new Error( `返回内容不是 JSON:HTTP ${response.status},${text.slice(0, 180)}` ); } state.requestLog.push({ label, url, attempt, httpStatus: response.status, apiCode: json?.code ?? null, apiMessage: json?.message ?? json?.msg ?? '', elapsedMs: Math.round(performance.now() - started) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } if (typeof json?.code === 'number' && json.code !== 0) { throw new Error( `B站接口 code=${json.code},message=${json.message || json.msg || ''}` ); } return json; } catch (error) { if (attempt < CONFIG.maxRetries) { const delay = CONFIG.retryBaseDelayMs * Math.pow(2, attempt - 1); log( `${label} 第 ${attempt} 次失败,${delay}ms 后重试:${error.message}`, 'warn' ); await sleep(delay); return fetchJson(url, label, attempt + 1); } throw error; } } async function getLoginInfo() { const json = await fetchJson( 'https://api.bilibili.com/x/web-interface/nav', '读取登录账号' ); return { isLogin: Boolean(json?.data?.isLogin), uid: normalizeUid(json?.data?.mid), name: safeString(json?.data?.uname) }; } async function getRelationStat(uid) { const json = await fetchJson( `https://api.bilibili.com/x/relation/stat?vmid=${encodeURIComponent(uid)}`, '读取粉丝总数' ); return { follower: Number(json?.data?.follower ?? 0), following: Number(json?.data?.following ?? 0), whisper: Number(json?.data?.whisper ?? 0), black: Number(json?.data?.black ?? 0) }; } async function getFollowerPage(uid, page) { const orderedEndpoints = state.selectedEndpoint ? [ state.selectedEndpoint, ...CONFIG.endpointCandidates.filter( (item) => item.name !== state.selectedEndpoint.name ) ] : [...CONFIG.endpointCandidates]; const failures = []; for (const endpoint of orderedEndpoints) { const url = endpoint.buildUrl(uid, page, CONFIG.pageSize); try { const json = await fetchJson( url, `读取粉丝第 ${page} 页(${endpoint.name})` ); const list = Array.isArray(json?.data?.list) ? json.data.list : []; state.selectedEndpoint = endpoint; state.endpointsUsed.add(endpoint.name); return { endpoint: endpoint.name, list, total: Number.isFinite(Number(json?.data?.total)) ? Number(json.data.total) : null, rawDataKeys: json?.data && typeof json.data === 'object' ? Object.keys(json.data) : [] }; } catch (error) { failures.push(`${endpoint.name}: ${error.message}`); } } throw new Error(failures.join(';')); } function createPanel() { const host = document.createElement('div'); host.id = TOOL_ID; host.style.position = 'fixed'; host.style.top = '18px'; host.style.right = '18px'; host.style.zIndex = '2147483647'; const root = host.attachShadow({ mode: 'open' }); root.innerHTML = ` `; 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(); })();