简介:BiliRaffle 是一款面向 C# 开发者与 B 站工具爱好者的动态抽奖组件,基于 bilibili-api 实现,可帮助用户快速搭建 B 站动态抽奖的自动化流程,适合具备一定 .NET 桌面开发基础、希望研究抽奖逻辑或二次开发的技术人员。资源包共 32 个文件,以 12 个 cs 源码文件与 4 个 xaml 界面文件为核心,辅以 config、xml、dll、sln、csproj 等工程配置与依赖文件,另有 png、ico、resx 等资源文件,整体约 60KB,结构紧凑、便于阅读与编译。项目采用 .NET Framework 4.5 及以上版本,解压后运行 BiliRaffle.exe 即可使用,源码涵盖登录窗口、主窗口、ViewModel、抽奖逻辑与 MVVM 辅助类等模块,目录组织清晰。目前已有 697 人学习下载,读者可借此理解 B 站动态抽奖的接口调用、界面绑定与业务分层思路,并在此基础上进行功能扩展或排错调试。
1. 抽奖工具还能这么写:一个 C# 组件把动态抽奖流程全包了
刷到一条动态抽奖,评论区几千条,手动翻?不现实。BiliRaffle 就是冲着这个场景来的——它是一个用 C# 写的 B站动态抽奖组件,核心能力是拉取指定动态下的转发/评论用户列表,按规则去重、过滤,最后随机抽出中奖者。适合两类人:一是想自己搭个抽奖工具但不想从零调接口的开发者,二是拿它当 bilibili-api 的 C# 封装参考来学。整个项目围绕 bilibili-api 的接口调用展开,把鉴权、分页拉取、数据清洗、随机抽取串成了一条完整链路。你拿到源码后,改几行配置就能跑,也能按自己的业务逻辑替换抽取策略。
2. 项目结构与技术选型:C# 怎么跟 bilibili-api 对接
2.1 目录骨架与职责划分
拿到源码先别急着跑,花两分钟把目录扫一遍,后面改代码能省不少时间。BiliRaffle 的结构是典型的 C# 类库 + 控制台入口的组合,常见做法是拆成三层:接口层、业务层、入口层。接口层负责跟 bilibili-api 打交道,封装 HTTP 请求和 Cookie 鉴权;业务层处理抽奖逻辑,包括去重、过滤、随机算法;入口层就是一个 Program.cs,读配置、调业务、输出结果。
一个合理的目录大概长这样:
BiliRaffle/ ├── BiliRaffle.Core/ │ ├── Api/ │ │ ├── BiliClient.cs # HTTP 客户端封装 │ │ └── DynamicApi.cs # 动态相关接口 │ ├── Models/ │ │ ├── UserInfo.cs # 用户数据模型 │ │ └── RaffleConfig.cs # 抽奖配置模型 │ ├── Services/ │ │ ├── RaffleService.cs # 抽奖核心逻辑 │ │ └── UserFilter.cs # 用户过滤与去重 │ └── Utils/ │ └── RandomHelper.cs # 随机抽取工具 ├── BiliRaffle.Console/ │ └── Program.cs # 控制台入口 └── BiliRaffle.sln这个划分的好处是,你想换抽取算法只动 Services,想换接口版本只动 Api,互不干扰。我一般会把配置项单独抽出来,不硬编码在代码里,后面会讲怎么改。
2.2 技术栈与依赖说明
项目用的是 .NET 6 或以上,语言是 C#。为什么选 C# 而不是 Python?一个现实原因是 bilibili-api 在 C# 生态里有现成的 HttpClient 封装方案,而且 C# 的强类型模型在处理接口返回的嵌套 JSON 时更省心——字段对不上编译期就报错,不用等到运行时才发现 KeyError。
核心依赖通常包括:
| 依赖 | 用途 | 备注 |
|---|---|---|
| System.Net.Http | HTTP 请求 | .NET 内置 |
| System.Text.Json | JSON 反序列化 | .NET 内置,也可换 Newtonsoft.Json |
| Microsoft.Extensions.Configuration | 读配置文件 | 可选,简单项目直接读 JSON 也行 |
如果你习惯用 Newtonsoft.Json,把反序列化那几行换掉就行,模型类不用动。这里没有引入太重的框架,整个项目保持轻量,编译出来就是一个 exe,扔到服务器上定时跑也没问题。
2.3 鉴权与请求封装
B站接口绕不开鉴权。BiliRaffle 的鉴权方式是在请求头里带 Cookie,核心是 SESSDATA 和 bili_jct 这两个值。SESSDATA 用来标识登录态,bili_jct 用于部分写操作(抽奖本身是读操作,但有些接口会校验)。
public class BiliClient { private readonly HttpClient _http; private readonly string _sessData; public BiliClient(string sessData) { _sessData = sessData; _http = new HttpClient(); // 常见做法:统一设置 UA 和 Referer,否则接口可能返回 -412 _http.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"); _http.DefaultRequestHeaders.Add("Referer", "https://www.bilibili.com"); _http.DefaultRequestHeaders.Add("Cookie", $"SESSDATA={_sessData}"); } public async Task<string> GetAsync(string url) { var resp = await _http.GetAsync(url); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsStringAsync(); } }这段代码的关键点有三个:一是 UA 和 Referer 必须带,不带的话接口大概率返回 -412 风控错误,这是血泪经验;二是 Cookie 拼接格式要对,SESSDATA 后面直接跟值,不要多加空格;三是 EnsureSuccessStatusCode 会在 HTTP 层报错时抛异常,方便定位问题。参数方面,sessData 从浏览器登录后的 Cookie 里复制,注意不要泄露给别人。
3. 核心模块拆解:从拉取用户到抽出中奖者的完整链路
3.1 动态评论/转发列表分页拉取
抽奖的数据来源是动态下的转发和评论。B站接口是分页返回的,一页最多 20 条左右,所以必须循环拉取直到没有下一页。这里最容易翻车的地方是分页参数和去重——接口返回的游标(next_offset)要正确传递,否则会重复拉同一页。
public async Task<List<UserInfo>> FetchAllUsers(string dynamicId) { var users = new List<UserInfo>(); string offset = ""; bool hasMore = true; while (hasMore) { // 构造分页请求,offset 为空表示第一页 var url = $"https://api.bilibili.com/x/v2/reply/main" + $"?type=11&oid={dynamicId}&mode=3&next={offset}"; var json = await _client.GetAsync(url); var result = JsonSerializer.Deserialize<ApiResponse<ReplyData>>(json); if (result?.Data?.Replies == null) break; foreach (var reply in result.Data.Replies) { users.Add(new UserInfo { Uid = reply.Member.Mid, Nickname = reply.Member.Uname }); } // 更新游标,判断是否还有下一页 offset = result.Data.Cursor?.Next.ToString() ?? ""; hasMore = result.Data.Cursor?.IsEnd == false && !string.IsNullOrEmpty(offset); // 加延迟,避免请求过快触发风控 await Task.Delay(800); } return users; }逻辑说明:type=11 表示动态类型,oid 是动态 ID,mode=3 是按时间排序。每次循环把当前页的用户加进列表,然后从 Cursor 里取下一页游标。is_end 为 true 或者游标为空就停止。参数方面,dynamicId 从动态链接里取,比如 t.bilibili.com/xxxxx 里的数字部分。那个 Task.Delay(800) 不是可有可无的,请求太快会被限流,800ms 是我试过比较稳的间隔,你可以根据实际情况调到 1000ms 以上。
3.2 用户去重与过滤规则
拉回来的用户列表里会有重复——同一个人可能转发多次,也可能既转发又评论。去重是抽奖公平性的基础,不去重的话活跃用户中奖概率会被放大。除了去重,通常还要过滤掉一些账号:比如官方账号、已注销账号、黑名单用户。
public class UserFilter { public List<UserInfo> Deduplicate(List<UserInfo> raw) { // 按 Uid 去重,保留第一个出现的记录 var seen = new HashSet<long>(); var result = new List<UserInfo>(); foreach (var u in raw) { if (seen.Add(u.Uid)) result.Add(u); } return result; } public List<UserInfo> ApplyRules(List<UserInfo> users, RaffleConfig config) { return users.Where(u => !config.ExcludeUids.Contains(u.Uid) && // 排除指定用户 !string.IsNullOrEmpty(u.Nickname) // 排除异常账号 ).ToList(); } }去重用的是 HashSet,时间复杂度 O(n),几千条数据毫秒级完成。ApplyRules 里可以按需扩展,比如加一个「关注时间早于某天」的过滤条件,或者「等级大于 2」的门槛。config.ExcludeUids 是一个配置项,把不想参与抽奖的 UID 填进去就行。注意 Nickname 为空的账号通常是已注销或异常号,直接过滤掉比较稳妥。
3.3 随机抽取与结果输出
抽取环节看起来简单,但随机数的质量直接影响公平性。用 Random 类就够了,但要注意不要每次 new 一个 Random,否则在快速连续调用时可能产生相同种子。正确做法是全局共享一个 Random 实例,或者用 RandomNumberGenerator。
public class RaffleService { private static readonly Random _rng = new Random(); public List<UserInfo> Draw(List<UserInfo> pool, int count) { if (count >= pool.Count) return pool; // 人数不够,全部中奖 // Fisher-Yates 洗牌,取前 count 个 var shuffled = new List<UserInfo>(pool); for (int i = shuffled.Count - 1; i > 0; i--) { int j = _rng.Next(i + 1); (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); } return shuffled.Take(count).ToList(); } }Fisher-Yates 洗牌的好处是每个元素被选中的概率严格相等,不会因为取前 N 个而偏向列表前面的用户。参数 count 是中奖人数,从配置里读。如果 pool 里的人数少于 count,直接全部返回,这个边界情况要处理,不然会抛异常。输出结果时把 Uid 和昵称一起打出来,方便核对。
4. 跑起来:配置、编译与运行步骤
4.1 环境准备与依赖还原
跑这个项目之前,确认机器上装了 .NET SDK 6.0 或更高版本。命令行敲dotnet --version能看到版本号就行。如果没装,去微软官方下载安装包,一路下一步即可。装完之后,进入项目根目录,执行依赖还原:
cd BiliRaffle dotnet restore这一步会根据 .csproj 文件里的 PackageReference 拉取 NuGet 包。如果网络慢,可以配一个国内镜像源,在NuGet.config里加一行就行。还原成功后,dotnet build编译一遍,确认没有报错。
4.2 配置文件怎么写
项目通常用一个appsettings.json或config.json来管理参数,不要把 SESSDATA 硬编码在代码里。一个典型的配置文件长这样:
{ "SessData": "你的SESSDATA值", "DynamicId": "动态ID数字", "WinnerCount": 3, "ExcludeUids": [123456, 789012], "RequestDelayMs": 800 }SessData 从浏览器开发者工具的 Application → Cookies 里复制,注意不要带多余空格。DynamicId 是动态链接里的数字。WinnerCount 是中奖人数。ExcludeUids 是排除列表,没有就留空数组。RequestDelayMs 控制请求间隔,网络好的话可以调到 500,但别低于 300,否则容易触发风控。
4.3 运行与结果验证
配置填好后,直接运行:
dotnet run --project BiliRaffle.Console控制台会先打印拉取进度,比如「已拉取 200 条,继续…」,拉完之后输出去重后的总人数,最后打印中奖名单。验证结果是否合理,可以看两个地方:一是总人数跟动态实际转发评论数是否大致吻合,差太多说明分页没拉全;二是中奖名单里有没有重复 UID,有的话说明去重逻辑没生效。如果中途报 -412 或 -509,基本是请求太快或者 Cookie 失效,换一个 SESSDATA 再试。
5. 避坑与常见问题排查
5.1 接口返回 -412 风控错误
现象:程序跑几页之后突然抛异常,接口返回码 -412。原因:请求频率过高,或者 UA/Referer 没带对。解决:把 RequestDelayMs 调到 1000 以上,确认 HttpClient 的 DefaultRequestHeaders 里 UA 和 Referer 都设置了。如果还是不行,换一个网络环境试试,有些 IP 段被风控得更严。
5.2 分页拉取重复或遗漏
现象:拉回来的用户列表里有大量重复,或者总人数明显偏少。原因:游标参数传递错误,或者 is_end 判断逻辑写反了。解决:打印每一页的 next 游标和 is_end 值,确认游标在递增、is_end 在最后一页变为 true。另外注意,有些接口的游标字段名不叫 next 而叫 offset,以实际返回为准。
5.3 SESSDATA 失效导致空结果
现象:程序不报错,但拉回来的用户列表是空的。原因:SESSDATA 过期了,接口返回的是未登录状态的数据。解决:重新登录 B站,从浏览器里复制新的 SESSDATA。注意 SESSDATA 里可能包含特殊字符,放进 JSON 时确认没有被转义。
5.4 中奖名单出现重复用户
现象:抽出来的中奖名单里同一个人出现两次。原因:去重步骤在抽取之后才执行,或者去重用的 HashSet 没有正确比较 Uid。解决:确保 Deduplicate 在 Draw 之前调用,并且 UserInfo 的 Uid 类型是 long 而不是 string,避免「123」和「123 」被当成两个不同的值。
5.5 编译报错找不到 System.Text.Json
现象:dotnet build时报错,提示找不到 System.Text.Json 命名空间。原因:项目目标框架低于 .NET Core 3.0,或者没有引入对应的 NuGet 包。解决:把 .csproj 里的 TargetFramework 改成 net6.0 或更高,或者手动dotnet add package System.Text.Json。
6. 二次开发:换抽取策略、加过滤条件和导出结果
拿到源码之后,最常改的三个地方是抽取策略、过滤规则和结果输出。先说抽取策略。默认用的是 Fisher-Yates 洗牌,公平但不够灵活。如果你想加权重——比如给充电用户更高的中奖概率——可以把 Draw 方法改成加权随机。
public List<UserInfo> DrawWeighted(List<UserInfo> pool, int count) { // 假设 UserInfo 有一个 Weight 属性,默认 1 var totalWeight = pool.Sum(u => u.Weight); var selected = new List<UserInfo>(); var remaining = new List<UserInfo>(pool); for (int n = 0; n < count && remaining.Count > 0; n++) { var r = _rng.NextDouble() * totalWeight; double cumulative = 0; for (int i = 0; i < remaining.Count; i++) { cumulative += remaining[i].Weight; if (cumulative >= r) { selected.Add(remaining[i]); totalWeight -= remaining[i].Weight; remaining.RemoveAt(i); break; } } } return selected; }这段代码的逻辑是:每次按权重比例选一个,选完从池子里移除并更新总权重。Weight 可以从配置文件里读,也可以根据用户等级、是否充电等条件动态计算。注意 totalWeight 要同步减去已选中用户的权重,否则概率会偏。
再说过滤条件。UserFilter 里的 ApplyRules 方法是一个扩展点,你可以加各种规则。比如只保留关注了某个账号的用户,或者排除最近 7 天新注册的账号。加规则的时候注意顺序——先去重再过滤,不然过滤完再去重可能把本该保留的用户误删。
最后是结果输出。默认是控制台打印,但实际用的时候往往需要导出成 CSV 或 Excel。加一个导出方法就行:
public void ExportCsv(List<UserInfo> winners, string path) { var lines = new List<string> { "Uid,Nickname" }; lines.AddRange(winners.Select(w => $"{w.Uid},{w.Nickname}")); File.WriteAllLines(path, lines); }导出之后用 Excel 打开核对,比在控制台里翻要方便得多。如果中奖人数多,还可以加一个「备选名单」——多抽几个作为候补,万一中奖者联系不上可以顺延。
我自己的习惯是,每次改完抽取逻辑,先用一个固定的随机种子跑一遍,确认结果可复现,再换成真随机跑正式抽奖。这样万一结果有争议,还能用种子复现过程。从那以后我每次动随机相关的代码,都强制走一遍「固定种子验证 → 真随机执行」的流程,省了不少扯皮。希望帮到你。
本文还有配套的精品资源,点击获取