资讯详情 BiliRaffle:C#实现B站动态抽奖的可复现取数与留痕
📅 2026/10/30 5:22:21
简介BiliRaffle 是一套面向 C# 开发者与 B 站活动工具使用者的动态抽奖组件基于 .NET Framework 4.5 及以上环境构建通过桌面端界面完成登录、抽奖配置与运行管理等操作适合学习 WPF 界面组织、MVVM 分层以及 B 站接口调用思路。压缩包共 32 个文件以 12 个 C# 源码和 4 个 XAML 界面文件为核心辅以配置文件、XML、依赖 DLL、解决方案与项目文件及图标资源整体约 60KB体量轻便便于直接解压运行和二次开发。已有 697 人浏览学习。项目将登录窗口、主界面、抽奖逻辑、视图模型、命令与转换器等模块拆分组织可帮助读者理解桌面抽奖工具的目录结构和模块职责也为继续扩展抽奖规则、界面交互及接口封装提供可参考的代码骨架。1. 抽奖不是随机数那么简单BiliRaffle 解决的三个真问题上个月帮一个做内容的朋友核对一次动态转发抽奖他把名单贴给我我一眼就看到同一个账号在两轮里各中了一次理由是“第二轮手动往下滚看到谁算谁”。这还算好的。更多现场是名单靠复制粘贴、去重靠人眼、结果靠一张截图事后有人质疑谁都拿不出可以复核的东西。BiliRaffle 就针对这段流程用 C# 实现了 B 站动态抽奖组件通过 bilibili-api 拉取指定动态下的转发与评论用户把关注状态、转发时间窗、等级这些条件做成可配置的过滤链再用固定种子的随机算法抽出中奖名单并导出。它服务三类人常做转发抽奖的 UP 主运营、需要批量整理动态互动名单的从业者、想拿一个真实接口项目练手的 C# 开发者。它不解决涨粉也不解决“怎么让抽奖看起来公平”它只解决三件事名单从哪来、规则怎么判、结果凭什么让人信。2. 拆开 BiliRaffle 的取数链路动态、转发与评论怎么拉抽奖组件最难的部分是取数抽签只是下一步。取数没做干净后面所有随机都是空谈。BiliRaffle 走的是网页端那套接口和浏览器里打开动态详情页看到的是同一份数据源所以它的能力边界也就是网页端的能力边界能看到转发列表能看到评论区能拿到用户的基本公开信息但拿不到私信、拿不到手机号也拿不到任何需要额外授权的隐私字段。这一点必须先说清楚因为很多人对“抽奖工具”的预期是“什么都能查”实际上做不到也不该做。2.1 凭据、请求头与请求频率先让接口认得出你B 站的多数动态接口在未登录状态下只返回很浅的一层数据转发列表往往直接给空或者只给前几条。所以第一件事是把凭据配好。常用做法是从浏览器已登录的会话里取出SESSDATA、bili_jct、buvid3三个值连同 User-Agent 一起塞进请求头。SESSDATA是身份bili_jct是 CSRF 校验用的令牌buvid3是设备标识缺了后面两个只读接口大多还能用但一旦涉及写操作或者风控判定就容易莫名其妙地返回 -352。配置文件我一般长这样单独放一个appsettings.json不提交到仓库用.gitignore挡掉{ Account: { Cookie: SESSDATA你的值; bili_jct你的值; buvid3你的值, UserAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36, Referer: https://www.bilibili.com/ }, Target: { DynamicId: 1234567890, PoolFromRepost: true, PoolFromComment: false }, RateLimit: { MinDelayMs: 800, MaxDelayMs: 1600, MaxRetry: 3, TimeoutSeconds: 15 } }RateLimit这一段是整份配置里最容易被新人删掉、又最容易出事的部分。MinDelayMs和MaxDelayMs之间做随机抖动是为了让请求间隔不是一个固定值固定间隔在服务端看就是一条等距的直线特征过于明显。MaxRetry控制在 3 次以内重试超过三次基本说明不是网络抖动而是被限了继续重试只会把冷却时间拉长。TimeoutSeconds给 15 秒是因为转发分页接口在名单很大时偶尔会慢到 8 秒以上默认的 100 秒又会让程序卡在一个已经死掉的连接上。凭据这块有一个血泪经验不要把SESSDATA当成长期有效的令牌。它的有效期与登录态绑定退出登录、改密码、异地登录都会让它失效表现就是本来能拉到 3000 条转发某天突然只返回 0 条code却还是 0。判断凭据是否还活着的办法不是看返回码而是看这一页的条数是否符合预期——第一页返回 20 条才算正常返回 0 条就该去检查登录态了。2.2 分页拉取offset 要跟着服务端走别自己加转发列表接口是https://api.bilibili.com/x/v2/repost参数里type1表示动态oid是动态 idoffset是游标ps是每页条数。这里有一个特别常见的翻车点很多人会写offset 20觉得每页 20 条加 20 就行了。实际不是。服务端返回的offset字段才是下一页的游标中间如果有人删楼、有人注销序号是断的自己加 20 会漏数据而且是静默漏——程序不报错只是少了几个人的转发记录抽奖结果自然就对不上。正确的拉法是这样用字典按mid去重天然把“同一个用户多次转发”和“跨页重复”两件事一起解决public async TaskListRepostUser FetchRepostsAsync(long dynamicId, CancellationToken ct) { // key 用 mid同一个人多次转发只留一条跨页重复也顺手处理掉 var pool new Dictionarylong, RepostUser(); long offset 0; int pageNo 0; while (true) { var url $https://api.bilibili.com/x/v2/repost?type1oid{dynamicId}offset{offset}ps20; using var req new HttpRequestMessage(HttpMethod.Get, url); req.Headers.TryAddWithoutValidation(Cookie, _cfg.Account.Cookie); req.Headers.TryAddWithoutValidation(User-Agent, _cfg.Account.UserAgent); req.Headers.TryAddWithoutValidation(Referer, _cfg.Account.Referer); using var resp await _http.SendAsync(req, ct); var body await resp.Content.ReadAsStringAsync(ct); var page JsonSerializer.DeserializeApiRespRepostPage(body, _jsonOpts); if (page is null || page.Code ! 0) throw new ApiException(page?.Code ?? -1, page?.Message ?? empty body); foreach (var item in page.Data.Items) pool[item.User.Mid] item.User; // 后写覆盖前写保留最新一条 pageNo; // 三个终止条件没有下一页、游标没动、翻页次数兜底 if (!page.Data.HasMore || page.Data.Offset offset) break; if (pageNo 500) break; offset page.Data.Offset; await Task.Delay(_rng.Next(_cfg.RateLimit.MinDelayMs, _cfg.RateLimit.MaxDelayMs 1), ct); } return pool.Values.ToList(); }代码里有三个地方值得单独说。pool[item.User.Mid] item.User用的是索引器而不是TryAdd目的是让同一个用户后一次转发覆盖前一次这样保留下来的repost_time是最新的后面判时间窗时不会因为用户“先转发后删除再转发”而误判。终止条件里加了page.Data.Offset offset是因为接口在名单到尾部时偶尔会返回has_more: true但游标原地不动如果不拦就是死循环。pageNo 500是兜底500 页已经是 1 万条量级正常抽奖场景不会超过超过大概率是游标异常。另外ps不建议改大。有人为了省请求次数会把它调到 49接口确实会返回更多但超长响应在弱网下更容易断断了重试又是整页重来算下来并不划算。按 20 条一页、每页间隔 1 秒左右1 万条转发大概 8 分钟拉完这个速度对抽奖来说完全够用。2.3 落库而不是留在内存一张表撑起后面的所有校验拉完就存在ListT里直接抽签是另一个常见做法也是另一个常见事故源。程序一崩名单全没了抽完想回答“为什么这个人没中”翻不出任何依据。bilibili-raffle 这一类工具的通行做法是先落库SQLite 单文件最省事不用装服务拷走就能复核。CREATE TABLE draw_pool ( mid BIGINT NOT NULL PRIMARY KEY, -- 用户 id全局唯一 uname TEXT NOT NULL, source TEXT NOT NULL, -- repost / comment repost_time INTEGER NOT NULL, -- Unix 秒判时间窗用 followed INTEGER NOT NULL DEFAULT 0, -- 1 已关注 level INTEGER NOT NULL DEFAULT 0, -- 当前等级 eligible INTEGER NOT NULL DEFAULT 1, -- 1 通过过滤 reject_reason TEXT -- 不通过时写原因 ); CREATE INDEX idx_pool_eligible ON draw_pool(eligible, source);字段设计上reject_reason是我强烈建议保留的。它让“这个人为什么没进池子”从一句口头解释变成一个可查询的字符串比如未关注、转发时间早于活动开始、等级不足。做过一次带争议的抽奖就知道能直接把这张表甩出去比在评论里解释一百字有效得多。eligible和source建联合索引是因为抽签前那次查询必然是WHERE eligible 1 AND source repost名单上万条时差别很明显。还有一点用户信息关注状态、等级是拉取那一刻的快照不是实时的。有人会在开奖前取关有人在开奖后关注。这个时间差没法消除能做的只有把快照时间也记下来写进结果文件让规则边界明确——“以拉取时刻的关注状态为准”比含糊的“需关注后参与”经得起追问。3. 在 C# 工程里把它跑起来依赖、配置与一条命令出结果取数链路理清之后剩下的工程问题反而简单。BiliRaffle 是个标准的 .NET 解决方案拆成三个项目BiliRaffle.Core放接口封装和数据模型BiliRaffle.Cli是命令行入口BiliRaffle.App是可选的可视化外壳。这种拆法不是为了好看是为了让“抽奖逻辑”和“界面”解耦——命令行能跑的东西换成桌面壳照样能跑也方便在服务器上定时执行。3.1 环境与依赖.NET 版本、HttpClient 单例与 JSON 选项运行时用 .NET 6 或以上8 更稳因为System.Text.Json的源生成器在 8 里对 AOT 和裁剪更友好。第三方包能少则少我一般只留Microsoft.Extensions.Configuration.Json读配置、Microsoft.Data.Sqlite落库、Spectre.Console命令行里打印进度表格可选。HTTP 请求不用第三方库直接用HttpClient。HttpClient有两个必须注意的点。第一不要每次请求都new HttpClient()那会把连接池打爆表现为跑几百次之后开始大量SocketException正确做法是通过IHttpClientFactory或者一个静态单例来复用。第二超时不要设在HttpClient.Timeout上那个值是全局的改一次影响所有请求用CancellationTokenSource给单次请求设超时更灵活// 注册整个进程共用一个实例连接池才不会被反复创建 services.AddHttpClientIBiliClient, BiliClient(c { c.DefaultRequestHeaders.ConnectionClose false; }); // 单次请求超时不要动 HttpClient.Timeout using var cts CancellationTokenSource.CreateLinkedTokenSource(outerCt); cts.CancelAfter(TimeSpan.FromSeconds(_cfg.RateLimit.TimeoutSeconds)); var resp await _http.SendAsync(req, cts.Token);反序列化那边B 站接口的字段名是下划线风格has_more、repost_time而 C# 属性是帕斯卡命名。用JsonSerializerOptions里配PropertyNamingPolicy JsonNamingPolicy.SnakeCaseLower一步到位不要在每个属性上挂[JsonPropertyName]字段一多就是灾难。数字字段要留意mid是 64 位整数用long接用int会在某些大号上溢出成负数然后去重逻辑把两个人当成一个人这种 bug 极难发现。3.2 配置逐项说明哪些必须改哪些别乱动配置分成四块Account、Target、RateLimit、Output。前两块是业务参数后两块是运行参数。下面这张表是我按“改了会怎样”的顺序排的配置项取值范围改了会发生什么建议Target.DynamicId动态链接里那串数字指向错误动态拉回来的名单完全是另一批人必改且改完先跑一次只拉不抽Target.PoolFromReposttrue / false关掉后只从评论区取人转发党全部出局按活动规则定两者可同时开RateLimit.MinDelayMs500 起调太低会触发限流返回 -412 或空页800 起步名单大就往上加RateLimit.MaxRetry1 到 5调高不会更快只会让冷却更久保持 3Output.CsvEncodingutf-8 / utf-8-bom用 Excel 直接打开无 BOM 的 CSV 会乱码给非技术同事看就选 bomDynamicId那一行的“先跑一次只拉不抽”值得展开。做法是加一个--dry-run开关只执行取数和过滤把draw_pool表打出来人眼扫一遍uname和条数。我见过最冤枉的一次事故是有人把动态 id 抄成了上一条动态的 id抽完发了奖中奖者里有一半根本没参与这次活动——名单本身是真的只是来自另一条动态。3.3 一条命令跑完从拉取到出结果命令行的参数设计尽量少能进配置文件的都进配置文件只留四个高频的动态 id、中奖人数、随机种子、输出目录。# 1. 还原依赖并编译 dotnet restore BiliRaffle.sln dotnet build -c Release # 2. 先空跑只拉名单和过滤不出中奖结果 dotnet run --project src/BiliRaffle.Cli -c Release -- \ --config ./config/appsettings.json \ --dynamic 1234567890 \ --dry-run # 3. 确认名单无误后正式抽 dotnet run --project src/BiliRaffle.Cli -c Release -- \ --config ./config/appsettings.json \ --dynamic 1234567890 \ --winners 10 \ --seed 20240601 \ --out ./out/三步的顺序不能反。第一步只验证编译和数据模型对不对第二步验证名单第三步才消耗随机。--seed是复现的关键同一天同一批名单、同一个种子抽出来的顺序必须完全一致如果两次跑出来不一样说明随机数用错了或者名单本身在两次之间变了比如实时重新拉了一次转发列表。--out目录下会生成pool.csv、winners.csv、run.log三个文件前者是过滤后的完整池子中间是结果后者是带时间戳的运行记录。一个细节--seed不要用当前时间自动生成后就丢掉。很多人图省事写new Random()抽完种子没记事后想复现完全不可能。我的做法是默认取参数值不传就生成一个yyyyMMddHHmmss格式的数字并打印在日志第一行逼自己留痕。4. 抽奖规则落到代码过滤链、固定种子与补抽逻辑规则这件事写进活动文案时是一句话落到代码里是一串判定。这两者的差距就是争议的来源。文案写“关注并转发即可参与”代码里如果还偷偷加了等级门槛那就是自己给自己埋雷。BiliRaffle 的做法是把所有判定显式化每一条规则对应一个函数每个不通过的人都记下原因。4.1 资格过滤链每一条规则都要能单独解释过滤链的顺序有讲究。先判最便宜的再判最贵的。关注状态和转发时间是本地已有的数据判起来零成本等级、粉丝牌这些需要额外请求用户信息接口的放到最后只对通过前面几关的人去查请求量能压下来一个数量级。public sealed record RuleContext( UserProfile User, DateTime RepostTime, DateTime OpenTime, DateTime CloseTime, int MinLevel, bool RequireFollow); public static bool IsEligible(RuleContext c, out string reason) { // 1. 关注本地字段先判 if (c.RequireFollow !c.User.Followed) { reason 未关注; return false; } // 2. 时间窗开奖时间段之外转发的一律不算 if (c.RepostTime c.OpenTime) { reason 转发早于活动开始; return false; } if (c.RepostTime c.CloseTime) { reason 转发晚于活动截止; return false; } // 3. 等级需要额外查用户信息放最后 if (c.User.Level c.MinLevel) { reason $等级不足({c.User.Level}); return false; } reason ; return true; }这段代码有三个设计取舍。第一用out string reason而不是返回bool?或者抛异常是因为“不通过”是正常业务分支不是错误用 out 参数最直白也不会带来异常开销。第二判断顺序按代价排followed和repost_time都是拉取时就带回来的level才对应用户信息接口。第三边界用闭区间还是开区间必须和文案一致“活动期间转发”通常是[OpenTime, CloseTime]两端都算代码里如果写成和就会把卡点转发的人排除掉而这些人往往是最积极的。时间窗还有一个隐蔽的坑时区。接口返回的repost_time是 Unix 秒转成DateTime时如果用了DateTimeKind.Unspecified再和本地时间构造的OpenTime比就会差 8 小时。统一做法是全程用DateTimeOffset或者统一转 UTC 再比。4.2 可复现随机Fisher-Yates 加固定种子“随机”在抽奖里不是形容词是可复现的意思。同一批名单、同一个种子必须得到同一个结果否则就无法自证。BiliRaffle 用的是 Fisher-Yates 洗牌加固定种子不用OrderBy(x Guid.NewGuid())那种写法——后者看起来简洁但 GUID 生成的随机源不受种子控制结果无法复现而且在大数据量下排序复杂度也不划算。public static ListT ShuffleT(IReadOnlyListT source, int seed) { var rng new Random(seed); // 固定种子 结果可复现 var arr source.ToList(); // 从后往前每个位置和 [0, i] 里随机一个位置交换 for (int i arr.Count - 1; i 0; i--) { int j rng.Next(i 1); (arr[i], arr[j]) (arr[j], arr[i]); } return arr; }rng.Next(i 1)的上界是i 1而不是i是因为Random.Next的上界是开区间写Next(i)永远取不到自己洗牌就不均匀了。这个差一错误在抽奖场景里后果不小位置越靠前的元素被换到后面的概率会偏低长期看是系统性偏差。还有一个必须写进文档的限制System.Random在 .NET 6 换过一次内部实现.NET 5 和 .NET 6 用同一个种子跑出来的序列不一样。所以复现的前提是“同一种子 同一运行时版本”。真要长期可复核可以在结果文件里把RuntimeInformation.FrameworkDescription一起写进去。4.3 权重与补抽重复中奖和多名额怎么处理实际活动里“一人多中”通常是要禁止的。做法不是抽完再查重而是抽之前就把池子按mid去重这一点在 2.2 的字典里已经做了。但如果活动允许“转发多次增加中奖概率”就需要权重这时候去重反而不能做得保留条数作为权重。权重抽样的常见做法是前缀和加二分把每个人的权重累加成数组总和为S取[0, S)的随机数二分找到落在哪个区间。这个方法比“按权重把元素复制多份再洗牌”省内存池子几千人、权重几十的时候差别很明显。补抽的逻辑要单独说。抽 10 个人抽完发现其中 2 个已经取关或者被判定为无效需要补 2 个。补抽不能重新洗一次牌那会把已经公示的结果打乱。正确做法是继续从洗好的序列里往下取直到凑够人数var shuffled Shuffle(eligibleUsers, seed); // 只洗一次 var winners new ListUserProfile(); var backup new ListUserProfile(); foreach (var u in shuffled) { if (winners.Count winnerCount) winners.Add(u); else if (backup.Count backupCount) backup.Add(u); // 候补名单同源顺序确定 else break; }候补名单和正式名单来自同一个洗牌序列这样“第 11 名候补”这个说法才有意义——它确实是序列里的第 11 位不是另外抽的一次。发奖时把候补顺序一起公示有人放弃资格就按顺序递补整个过程不需要再跑一次程序。5. 避坑与排查五个把抽奖搞砸的现场这一节里的五条都是我在实际场景里见过或者亲自踩过的每条按现象、原因、解决写遇到类似症状可以直接对号入座。现象一第一页能拉到 20 条第二页开始全空返回码还是 0。原因基本是两个一是offset用了自增而不是服务端回传值二是凭据在这一步失效但接口仍然返回 200。排查顺序是先打印第二页请求的完整 URL看offset是不是 0 一直没变再手动把同一个 URL 贴到浏览器已登录的会话里请求一次看返回条数。如果浏览器能拉到、程序拉不到问题就在 Cookie 头的拼装——有的是少了SESSDATA结尾的分号有的是TryAddWithoutValidation被后面的默认头覆盖了。现象二抽出来的中奖名单里出现了根本没转发的人。这是典型的池子污染。常见来源是“转发”和“评论”两个来源合并时没有区分source字段评论区的路人被当成了转发参与者。解决是在draw_pool表里强制写source抽签前的查询必须带WHERE source repost。如果活动规则是“转发或评论都可参与”那就在查询里明确写source IN (repost,comment)别图省事用全表。现象三同一个种子跑两次结果不一样。先确认名单本身有没有变——如果程序每次都重新拉一次转发列表两次之间新增了几条转发池子不同结果当然不同。做法是把“拉取”和“抽取”拆成两步抽取只读本地的pool.csv。如果名单确认没变还是不一样那就去看Random的用法有没有在别的地方也new Random(seed)导致序列被消耗或者有没有用Guid、DateTime.Now.Ticks之类的非种子源。还有一个容易忽略的点.NET 版本升级后Random序列会变跨版本比对必然对不上。现象四跑到一半抛TaskCanceledException日志里看不出是哪一页。默认的HttpClient.Timeout是 100 秒超时抛的就是TaskCanceledException和用户主动取消长得一模一样很难区分。解决是给每次请求单独套CancellationTokenSource捕获异常时把当前页码、offset、已拉条数一起打进日志。这样崩了也能从断点续拉而不是从头再来。名录上万条时从头再来一次的代价是十几分钟和一轮额外的限流风险。现象五导出的 CSV 用 Excel 打开是乱码中奖用户 id 变成了科学计数法。乱码是编码问题加 UTF-8 BOM 解决科学计数法是 Excel 对长数字的默认处理18 位的mid会被显示成1.23457E17看起来像另一个人。解决是在 CSV 里给mid字段加制表符前缀或者直接输出成1234567890这种公式形式或者干脆同时给一份.xlsx。我一般两个都给CSV 给程序读XLSX 给人看。6. 结果校验与留痕让一次抽奖经得起三次追问抽奖这件事做完不等于做完。真正的收尾是三天后有人问“为什么我没中”你能在三十秒内给出答案而且这个答案不需要你回忆。BiliRaffle 的输出目录里我固定放四样东西pool.csv过滤后的完整池子含reject_reason、winners.csv中奖与候补顺序、run.log每页请求的页码、条数、耗时、重试次数、manifest.sha256前三个文件的摘要。校验分三层从便宜到贵校验层做法能发现什么问题条数核对池子条数 被拒条数 拉取总条数漏页、去重过度、过滤链中途抛异常分布抽检随机抽 20 条被拒记录人工看reject_reason是否成立规则写反比如Level 2写成Level 2摘要留痕对输出文件算 SHA-256写进manifest事后文件被改过、不同人手里版本不一致第三层很多人觉得多余直到出现“我这边看到的中奖名单和你发的不一样”。算摘要一行命令的事# 输出目录里生成摘要清单连同结果一起归档 cd out/ sha256sum pool.csv winners.csv run.log manifest.sha256 # 任何人拿到文件后都能自己验一遍 sha256sum -c manifest.sha256sha256sum -c会逐行比对并输出OK或FAILED。这个动作的意义不在于防谁而在于把“这份文件就是当时那份”变成一件可验证的事而不是一句“我保证没改”。名单公示的时候把manifest.sha256一起贴出去懂的人自然会验不懂的人看到这一串也会觉得这事是认真做的。还有一个进阶用法值得说把seed和pool.csv的摘要拼起来作为这次抽奖的指纹写进公示文案。比如seed20240601, pool3f9a...。这样一来任何人只要拿到当时的池子文件和种子就能用同一份程序复算出完全一致的中奖序列不需要 trust 你只需要 trust 算法。这套做法在业内不算新鲜但真正落到抽奖这种小场景里的不多做到了争议基本就没了。从那以后我每次做抽奖不管规模多小都强制走一遍“先 dry-run 看名单、再固定种子抽、最后算摘要归档”这三步一步都不省。省下的那十分钟往往要用一晚上的解释去还。希望帮到你。本文还有配套的精品资源点击获取