diff --git a/BarrageGrab/App.config b/BarrageGrab/App.config index c298622..729897a 100644 --- a/BarrageGrab/App.config +++ b/BarrageGrab/App.config @@ -1,107 +1,107 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - + - + - \ No newline at end of file + diff --git a/BarrageGrab/AppConfig.json b/BarrageGrab/AppConfig.json index 7497daa..f7887f6 100644 --- a/BarrageGrab/AppConfig.json +++ b/BarrageGrab/AppConfig.json @@ -5,7 +5,9 @@ // 是否隐藏控制台 "hideConsole": false, // 显示窗体 - "showWindow": false + "showWindow": true, + // 直播平台: "douyin" 或 "kuaishou" + "platform": "douyin" }, "network": { "proxy": { @@ -25,7 +27,7 @@ }, "filtering": { // 过滤Websocket数据源进程,可用','进行分隔,程序将会监听以下进程的弹幕信息 - "processFilter": "直播伴侣,douyin,chrome,msedge,QQBrowser,360se,firefox,2345explorer,iexplore", + "processFilter": "直播伴侣,douyin,kuaishou,kwailive,bilibili,chrome,msedge,QQBrowser,360se,firefox,2345explorer,iexplore", // 开启内置的域名过滤,设置为false会解包所有https请求,cpu占用很高,建议在无法获取弹幕数据时调整 "hostNameEnabled": true, // 已知的弹幕域名列表 ','分隔 用作过滤规则中,凡是webcast开头的域名程序都会自动列入白名单 diff --git a/BarrageGrab/AppSetting.cs b/BarrageGrab/AppSetting.cs index f7bbc1e..dacce13 100644 --- a/BarrageGrab/AppSetting.cs +++ b/BarrageGrab/AppSetting.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; using BarrageGrab.Modles.JsonEntity; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using static System.Configuration.ConfigurationManager; @@ -46,6 +47,7 @@ namespace BarrageGrab ConfigComPort(); ConfigFilter(); + LoadFromJson(); } catch (Exception ex) { @@ -72,8 +74,11 @@ namespace BarrageGrab // 读取JSON文件内容(保留注释) string jsonContent = File.ReadAllText(jsonFilePath); - // 解析JSON(Newtonsoft.Json支持JSONC格式,会自动忽略注释) - JObject config = JObject.Parse(jsonContent); + // 解析JSON(忽略注释) + JObject config = JObject.Parse(jsonContent, new JsonLoadSettings + { + CommentHandling = CommentHandling.Ignore + }); // 从JSON读取应用配置 var app = config["app"]; @@ -81,7 +86,16 @@ namespace BarrageGrab // 常规配置 var general = app["general"]; HideConsole = general["hideConsole"]?.Value() ?? false; - ShowWindow = general["showWindow"]?.Value() ?? false; + ShowWindow = general["showWindow"]?.Value() ?? false; + string platformStr = general["platform"]?.Value() ?? "douyin"; + if (platformStr.ToLower() == "kuaishou") + { + Platform = LivePlatform.Kuaishou; + } + else + { + Platform = LivePlatform.Douyin; + } // 网络配置 var network = app["network"]; @@ -640,6 +654,11 @@ namespace BarrageGrab /// public string LiveCompanPath { get; set; } = string.Empty; + /// + /// 直播平台 (Douyin / Kuaishou) + /// + public LivePlatform Platform { get; set; } = LivePlatform.Douyin; + /// /// hook直播伴侣代理开关 /// diff --git a/BarrageGrab/BUILD.bat b/BarrageGrab/BUILD.bat new file mode 100644 index 0000000..90464e0 --- /dev/null +++ b/BarrageGrab/BUILD.bat @@ -0,0 +1,32 @@ +@echo off +REM JiaKong 配置编辑器编译脚本 +REM 在 VS 中打开 WssBarrageService.csproj 编译即可 +REM 或者双击此脚本(需要安装 Visual Studio 2022) + +echo 正在查找 MSBuild... + +REM VS 2022 MSBuild 路径 +set MSBUILD="C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" + +if not exist %MSBUILD% ( + set MSBUILD="C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" +) + +if not exist %MSBUILD% ( + echo 未找到 MSBuild.exe,请在 Visual Studio 中打开项目并编译。 + pause + exit /b 1 +) + +echo 正在编译 WssBarrageService... +%MSBUILD% WssBarrageService.csproj /t:Build /p:Configuration=Debug /nologo + +if %ERRORLEVEL% EQU 0 ( + echo. + echo 编译成功! + echo 输出: bin\Debug\WssBarrageServer.exe +) else ( + echo. + echo 编译失败,请检查错误信息。 +) +pause diff --git a/BarrageGrab/Forms/ConfigEditorForm.cs b/BarrageGrab/Forms/ConfigEditorForm.cs new file mode 100644 index 0000000..c0f89c0 --- /dev/null +++ b/BarrageGrab/Forms/ConfigEditorForm.cs @@ -0,0 +1,523 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Windows.Forms; + +namespace BarrageGrab.Forms +{ + /// + /// 游戏配置编辑器 —— 可视化编辑5个配置文件 + /// 1. effect_config.txt - 基础效果库(下拉框选项源) + /// 2. macro_config.txt - 组合效果(宏) + /// 3. gift_config.txt - 礼物/事件→效果映射 + /// 4. command_config.txt - 弹幕指令触发词 + /// 5. fkey_config.txt - F键→效果映射 + /// + public partial class ConfigEditorForm : Form + { + private DataGridView dgvEffects, dgvMacros, dgvGifts, dgvCommands, dgvFkeys; + private TabControl tabMain; + private string ConfigDir { get { return Program.ConfigDir; } } + private Label lblStatus; + + // 文件路径 + private string EffectPath => Program.EffectPath; + private string MacroPath => Program.MacroPath; + private string GiftPath => Program.GiftPath; + private string CmdPath => Program.CommandPath; + private string FkeyPath => Program.FkeyPath; + + private readonly string[] FKeyOptions = { "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12" }; + + // CommandType 列表(用于指令Tab) + private readonly string[] CommandTypes = { + "SelectState", "SpawnTroop", "Gold", "Population", "Destiny", + "Mobilize", "Attack", "Alliance", "ResearchTech", "SetIdeology", + "ApplyBuff", "Info" + }; + + public ConfigEditorForm() + { + InitializeComponent(); + this.Text = "游戏配置编辑器 (5文件)"; + this.ClientSize = new Size(950, 650); + this.StartPosition = FormStartPosition.CenterParent; + BuildUI(); + LoadAll(); + } + + private void InitializeComponent() + { + this.SuspendLayout(); + this.AutoScaleMode = AutoScaleMode.Font; + this.ResumeLayout(false); + } + + // ═══════════════ UI 构建 ═══════════════ + + void BuildUI() + { + tabMain = new TabControl { Dock = DockStyle.Fill, Name = "tabMain" }; + this.Controls.Add(tabMain); + + // Tab1: 基础效果 + var tabEffect = new TabPage { Text = "基础效果", Name = "tabEffect" }; + dgvEffects = CreateEffectGrid(); + tabEffect.Controls.Add(dgvEffects); + tabEffect.Controls.Add(CreateButtonPanel(() => AddRow(dgvEffects, "新效果", "出兵 3"), + "格式: 效果名称|游戏指令 | 原子效果,下拉框选项源")); + tabEffect.Controls[1].Dock = DockStyle.Bottom; + dgvEffects.Dock = DockStyle.Fill; + tabMain.TabPages.Add(tabEffect); + + // Tab2: 组合宏 + var tabMacro = new TabPage { Text = "组合宏", Name = "tabMacro" }; + dgvMacros = CreateMacroGrid(); + tabMacro.Controls.Add(dgvMacros); + tabMacro.Controls.Add(CreateButtonPanel(() => AddRow(dgvMacros, "新宏", "", "出兵 5|金币 100"), + "格式: 宏名称|触发词(可空)|指令1|指令2 | 指令支持$1 $2 $*")); + tabMacro.Controls[1].Dock = DockStyle.Bottom; + dgvMacros.Dock = DockStyle.Fill; + tabMain.TabPages.Add(tabMacro); + + // Tab3: 礼物事件 + var tabGift = new TabPage { Text = "礼物事件", Name = "tabGift" }; + dgvGifts = CreateGiftGrid(); + tabGift.Controls.Add(dgvGifts); + tabGift.Controls.Add(CreateButtonPanel(() => AddRow(dgvGifts, "新礼物", "", "{0} 送了礼物", ""), + "格式: 关键词|效果名称|公告|间隔 | 效果名称从下拉框选")); + tabGift.Controls[1].Dock = DockStyle.Bottom; + dgvGifts.Dock = DockStyle.Fill; + tabMain.TabPages.Add(tabGift); + + // Tab4: 弹幕指令 + var tabCmd = new TabPage { Text = "弹幕指令", Name = "tabCmd" }; + dgvCommands = CreateCommandGrid(); + tabCmd.Controls.Add(dgvCommands); + tabCmd.Controls.Add(CreateButtonPanel(() => AddRow(dgvCommands, "SelectState", "选择,选,select"), + "格式: [CommandType]|触发词1,触发词2 | 宏在'组合宏'Tab配置")); + tabCmd.Controls[1].Dock = DockStyle.Bottom; + dgvCommands.Dock = DockStyle.Fill; + tabMain.TabPages.Add(tabCmd); + + // Tab5: F键 + var tabFkey = new TabPage { Text = "F键", Name = "tabFkey" }; + dgvFkeys = CreateFkeyGrid(); + tabFkey.Controls.Add(dgvFkeys); + tabFkey.Controls.Add(CreateButtonPanel(() => AddRow(dgvFkeys, "F2", ""), + "格式: F键|效果名称 | 效果名称从下拉框选 | F1固定初始化")); + tabFkey.Controls[1].Dock = DockStyle.Bottom; + dgvFkeys.Dock = DockStyle.Fill; + tabMain.TabPages.Add(tabFkey); + } + + Panel CreateButtonPanel(Action addAction, string hint) + { + var p = new Panel { Height = 40 }; + var btnAdd = new Button { Text = "+ 新增", Left = 5, Top = 8, Width = 70 }; + btnAdd.Click += (s, e) => addAction(); + p.Controls.Add(btnAdd); + + var btnDel = new Button { Text = "删除选中", Left = 80, Top = 8, Width = 70 }; + btnDel.Click += (s, e) => + { + var dgv = GetCurrentGrid(); + if (dgv?.SelectedRows.Count > 0) + foreach (DataGridViewRow row in dgv.SelectedRows) + if (!row.IsNewRow) dgv.Rows.Remove(row); + }; + p.Controls.Add(btnDel); + + var btnReload = new Button { Text = "重新加载", Left = 155, Top = 8, Width = 80 }; + btnReload.Click += (s, e) => { LoadAll(); }; + p.Controls.Add(btnReload); + + var btnSave = new Button { Text = "全部保存", Left = 240, Top = 8, Width = 90, BackColor = Color.LightGreen }; + btnSave.Click += (s, e) => SaveAll(); + p.Controls.Add(btnSave); + + lblStatus = new Label { AutoSize = true, Left = 340, Top = 12, Text = "" }; + p.Controls.Add(lblStatus); + + var lblHint = new Label { AutoSize = true, Left = 500, Top = 12, ForeColor = Color.Gray, Text = hint }; + p.Controls.Add(lblHint); + return p; + } + + DataGridView GetCurrentGrid() + { + switch (tabMain.SelectedIndex) + { + case 0: return dgvEffects; + case 1: return dgvMacros; + case 2: return dgvGifts; + case 3: return dgvCommands; + case 4: return dgvFkeys; + default: return null; + } + } + + void AddRow(DataGridView dgv, params object[] values) => dgv.Rows.Add(values); + + // ═══════════════ 表格创建 ═══════════════ + + DataGridView CreateEffectGrid() + { + var dgv = CreateBaseGrid("dgvEffects"); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "效果名称", Name = "Name", Width = 150 }); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "游戏指令", Name = "Command", Width = 300 }); + return dgv; + } + + DataGridView CreateMacroGrid() + { + var dgv = CreateBaseGrid("dgvMacros"); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "宏名称", Name = "Name", Width = 120 }); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "触发词(逗号分隔,可空)", Name = "Triggers", Width = 150 }); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "指令(多条用|分隔,支持$1 $2 $*)", Name = "Commands", Width = 350 }); + return dgv; + } + + DataGridView CreateGiftGrid() + { + var dgv = CreateBaseGrid("dgvGifts"); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "关键词(礼物名/事件)", Name = "Keyword", Width = 120 }); + var colEffect = new DataGridViewComboBoxColumn { HeaderText = "效果名称", Name = "Effect", Width = 180 }; + colEffect.AutoComplete = true; + dgv.Columns.Add(colEffect); + dgv.DataError += (s, e) => { e.ThrowException = false; }; + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "公告文本", Name = "Announce", Width = 200 }); + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "间隔", Name = "Interval", Width = 50 }); + return dgv; + } + + DataGridView CreateCommandGrid() + { + var dgv = CreateBaseGrid("dgvCommands"); + var colType = new DataGridViewComboBoxColumn { HeaderText = "CommandType", Name = "CmdType", Width = 120 }; + colType.Items.AddRange(CommandTypes); + dgv.Columns.Add(colType); + dgv.DataError += (s, e) => { e.ThrowException = false; }; + dgv.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "触发词(逗号分隔)", Name = "Triggers", Width = 400 }); + return dgv; + } + + DataGridView CreateFkeyGrid() + { + var dgv = CreateBaseGrid("dgvFkeys"); + var colKey = new DataGridViewComboBoxColumn { HeaderText = "F键", Name = "FKey", Width = 80 }; + colKey.Items.AddRange(FKeyOptions); + dgv.Columns.Add(colKey); + var colEffect = new DataGridViewComboBoxColumn { HeaderText = "效果名称", Name = "Effect", Width = 250 }; + colEffect.AutoComplete = true; + dgv.Columns.Add(colEffect); + dgv.DataError += (s, e) => { e.ThrowException = false; }; + return dgv; + } + + DataGridView CreateBaseGrid(string name) + { + return new DataGridView + { + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + Name = name + }; + } + + // ═══════════════ 效果下拉刷新 ═══════════════ + + /// 刷新礼物/F键Tab的效果下拉框(从effect_config + macro_config读取) + void RefreshEffectDropdowns() + { + var effectNames = new List(); + + // 从基础效果表读取 + foreach (DataGridViewRow row in dgvEffects.Rows) + { + if (row.IsNewRow) continue; + var name = row.Cells["Name"].Value?.ToString(); + if (!string.IsNullOrEmpty(name)) effectNames.Add(name); + } + + // 从宏表读取 + foreach (DataGridViewRow row in dgvMacros.Rows) + { + if (row.IsNewRow) continue; + var name = row.Cells["Name"].Value?.ToString(); + if (!string.IsNullOrEmpty(name)) effectNames.Add(name); + } + + // 刷新礼物Tab + var giftCol = dgvGifts.Columns["Effect"] as DataGridViewComboBoxColumn; + if (giftCol != null) + { + var oldValues = new List(); + foreach (DataGridViewRow row in dgvGifts.Rows) + { + if (row.IsNewRow) continue; + var v = row.Cells["Effect"].Value?.ToString(); + if (!string.IsNullOrEmpty(v)) oldValues.Add(v); + } + giftCol.Items.Clear(); + foreach (var n in effectNames) giftCol.Items.Add(n); + foreach (var v in oldValues) + if (!giftCol.Items.Contains(v)) giftCol.Items.Add(v); + } + + // 刷新F键Tab + var fkeyCol = dgvFkeys.Columns["Effect"] as DataGridViewComboBoxColumn; + if (fkeyCol != null) + { + var oldValues = new List(); + foreach (DataGridViewRow row in dgvFkeys.Rows) + { + if (row.IsNewRow) continue; + var v = row.Cells["Effect"].Value?.ToString(); + if (!string.IsNullOrEmpty(v)) oldValues.Add(v); + } + fkeyCol.Items.Clear(); + foreach (var n in effectNames) fkeyCol.Items.Add(n); + foreach (var v in oldValues) + if (!fkeyCol.Items.Contains(v)) fkeyCol.Items.Add(v); + } + } + + // ═══════════════ 加载 ═══════════════ + + void LoadAll() + { + if (!Directory.Exists(ConfigDir)) Directory.CreateDirectory(ConfigDir); + LoadEffects(); + LoadMacros(); + LoadGifts(); + LoadCommands(); + LoadFkeys(); + RefreshEffectDropdowns(); + lblStatus.Text = "配置已加载"; + lblStatus.ForeColor = Color.Black; + } + + void LoadEffects() + { + dgvEffects.Rows.Clear(); + if (!File.Exists(EffectPath)) return; + foreach (var line in File.ReadAllLines(EffectPath, Encoding.UTF8)) + { + var t = line.Trim(); + if (string.IsNullOrEmpty(t) || t.StartsWith("#")) continue; + int pipe = t.IndexOf('|'); + if (pipe <= 0) continue; + dgvEffects.Rows.Add(t.Substring(0, pipe).Trim(), t.Substring(pipe + 1).Trim()); + } + } + + void LoadMacros() + { + dgvMacros.Rows.Clear(); + if (!File.Exists(MacroPath)) return; + foreach (var line in File.ReadAllLines(MacroPath, Encoding.UTF8)) + { + var t = line.Trim(); + if (string.IsNullOrEmpty(t) || t.StartsWith("#")) continue; + var parts = t.Split('|'); + if (parts.Length < 3) continue; + var name = parts[0].Trim(); + var triggers = parts[1].Trim(); + var cmds = string.Join("|", parts.Skip(2).Select(p => p.Trim())); + dgvMacros.Rows.Add(name, triggers, cmds); + } + } + + void LoadGifts() + { + dgvGifts.Rows.Clear(); + if (!File.Exists(GiftPath)) return; + foreach (var line in File.ReadAllLines(GiftPath, Encoding.UTF8)) + { + var t = line.Trim(); + if (string.IsNullOrEmpty(t) || t.StartsWith("#")) continue; + var parts = t.Split('|'); + if (parts.Length < 2) continue; + var keyword = parts[0].Trim(); + var effect = parts[1].Trim(); + var announce = parts.Length > 2 ? parts[2].Trim() : ""; + var interval = parts.Length > 3 ? parts[3].Trim() : ""; + dgvGifts.Rows.Add(keyword, effect, announce, interval); + } + } + + void LoadCommands() + { + dgvCommands.Rows.Clear(); + if (!File.Exists(CmdPath)) return; + foreach (var line in File.ReadAllLines(CmdPath, Encoding.UTF8)) + { + var t = line.Trim(); + if (string.IsNullOrEmpty(t) || t.StartsWith("#")) continue; + if (!t.StartsWith("[")) continue; + int end = t.IndexOf(']'); + if (end <= 1) continue; + var typeName = t.Substring(1, end - 1).Trim(); + var triggers = t.Substring(end + 1).TrimStart('|').Trim(); + dgvCommands.Rows.Add(typeName, triggers); + } + } + + void LoadFkeys() + { + dgvFkeys.Rows.Clear(); + if (!File.Exists(FkeyPath)) + { + // 默认值 + var defaults = new[] { + ("F2","出兵5"), ("F3","金币300"), ("F4","出兵3"), ("F5","金币500"), + ("F6","攻击力x2_30s"), ("F7","人口1000"), ("F8","天命200"), + ("F9","科技全开"), ("F10","特征全开"), ("F11","动员5"), ("F12","大礼包") + }; + foreach (var d in defaults) dgvFkeys.Rows.Add(d.Item1, d.Item2); + return; + } + foreach (var line in File.ReadAllLines(FkeyPath, Encoding.UTF8)) + { + var t = line.Trim(); + if (string.IsNullOrEmpty(t) || t.StartsWith("#")) continue; + int pipe = t.IndexOf('|'); + if (pipe <= 0) continue; + dgvFkeys.Rows.Add(t.Substring(0, pipe).Trim(), t.Substring(pipe + 1).Trim()); + } + } + + // ═══════════════ 保存 ═══════════════ + + void SaveAll() + { + try + { + if (!Directory.Exists(ConfigDir)) Directory.CreateDirectory(ConfigDir); + SaveEffects(); + SaveMacros(); + SaveGifts(); + SaveCommands(); + SaveFkeys(); + RefreshEffectDropdowns(); + lblStatus.Text = "全部保存成功"; + lblStatus.ForeColor = Color.Green; + } + catch (Exception ex) + { + lblStatus.Text = $"保存失败: {ex.Message}"; + lblStatus.ForeColor = Color.Red; + } + } + + void SaveEffects() + { + var sb = new StringBuilder(); + sb.AppendLine("# 游戏基础效果配置 v1"); + sb.AppendLine("# 格式: 效果名称|游戏指令"); + sb.AppendLine("# 一个效果 = 一条原子指令(多指令组合用 macro_config)"); + sb.AppendLine(); + foreach (DataGridViewRow row in dgvEffects.Rows) + { + if (row.IsNewRow) continue; + var name = row.Cells["Name"].Value?.ToString() ?? ""; + var cmd = row.Cells["Command"].Value?.ToString() ?? ""; + if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(cmd)) + sb.AppendLine($"{name}|{cmd}"); + } + File.WriteAllText(EffectPath, sb.ToString(), Encoding.UTF8); + } + + void SaveMacros() + { + var sb = new StringBuilder(); + sb.AppendLine("# 游戏组合效果(宏)配置 v1"); + sb.AppendLine("# 格式: 宏名称|触发词1,触发词2,...|指令1|指令2|..."); + sb.AppendLine("# 触发词可选(留空则只能被礼物/F键引用)"); + sb.AppendLine("# 指令支持 $1 $2 $* 参数替换(仅弹幕触发时)"); + sb.AppendLine(); + foreach (DataGridViewRow row in dgvMacros.Rows) + { + if (row.IsNewRow) continue; + var name = row.Cells["Name"].Value?.ToString() ?? ""; + var triggers = row.Cells["Triggers"].Value?.ToString() ?? ""; + var cmds = row.Cells["Commands"].Value?.ToString() ?? ""; + if (string.IsNullOrEmpty(name)) continue; + // cmds 中的 | 是指令分隔符,保持原样 + sb.AppendLine($"{name}|{triggers}|{cmds}"); + } + File.WriteAllText(MacroPath, sb.ToString(), Encoding.UTF8); + } + + void SaveGifts() + { + var sb = new StringBuilder(); + sb.AppendLine("# 游戏礼物/事件配置 v1"); + sb.AppendLine("# 格式: 关键词|效果名称|公告文本|触发间隔(可选)"); + sb.AppendLine("# 效果名称引用 effect_config 或 macro_config(下拉框选择)"); + sb.AppendLine("# {0}=昵称 {1}=数量"); + sb.AppendLine("# 特殊关键词: 点赞/关注/进房/分享/粉丝团"); + sb.AppendLine(); + foreach (DataGridViewRow row in dgvGifts.Rows) + { + if (row.IsNewRow) continue; + var keyword = row.Cells["Keyword"].Value?.ToString() ?? ""; + var effect = row.Cells["Effect"].Value?.ToString() ?? ""; + var msg = row.Cells["Announce"].Value?.ToString() ?? ""; + var interval = row.Cells["Interval"].Value?.ToString() ?? ""; + if (string.IsNullOrEmpty(keyword) || string.IsNullOrEmpty(effect)) continue; + var parts = new List { keyword, effect }; + if (!string.IsNullOrEmpty(msg)) parts.Add(msg); + if (!string.IsNullOrEmpty(interval)) parts.Add(interval); + sb.AppendLine(string.Join("|", parts)); + } + File.WriteAllText(GiftPath, sb.ToString(), Encoding.UTF8); + } + + void SaveCommands() + { + var sb = new StringBuilder(); + sb.AppendLine("# 游戏弹幕指令触发词配置 v1"); + sb.AppendLine("# 格式: [CommandType]|触发词1,触发词2,..."); + sb.AppendLine("# 注: 宏指令在 macro_config.txt 配置"); + sb.AppendLine(); + foreach (DataGridViewRow row in dgvCommands.Rows) + { + if (row.IsNewRow) continue; + var type = row.Cells["CmdType"].Value?.ToString() ?? ""; + var triggers = row.Cells["Triggers"].Value?.ToString() ?? ""; + if (string.IsNullOrEmpty(type)) continue; + sb.AppendLine($"[{type}]|{triggers}"); + } + File.WriteAllText(CmdPath, sb.ToString(), Encoding.UTF8); + } + + void SaveFkeys() + { + var sb = new StringBuilder(); + sb.AppendLine("# 游戏 F键配置 v1"); + sb.AppendLine("# 格式: F键|效果名称"); + sb.AppendLine("# 效果名称引用 effect_config 或 macro_config"); + sb.AppendLine("# F1 固定为初始化,不可修改"); + sb.AppendLine(); + foreach (DataGridViewRow row in dgvFkeys.Rows) + { + if (row.IsNewRow) continue; + var key = row.Cells["FKey"].Value?.ToString() ?? ""; + var effect = row.Cells["Effect"].Value?.ToString() ?? ""; + if (string.IsNullOrEmpty(key)) continue; + sb.AppendLine($"{key}|{effect}"); + } + File.WriteAllText(FkeyPath, sb.ToString(), Encoding.UTF8); + } + } +} diff --git a/BarrageGrab/Forms/FormView.Designer.cs b/BarrageGrab/Forms/FormView.Designer.cs index 6006828..3a49e76 100644 --- a/BarrageGrab/Forms/FormView.Designer.cs +++ b/BarrageGrab/Forms/FormView.Designer.cs @@ -26,6 +26,7 @@ /// Required method for Designer support - do not modify /// the contents of this method with the code editor. /// + private void InitializeComponent() { this.rich_output = new System.Windows.Forms.RichTextBox(); @@ -36,6 +37,8 @@ this.flowLayoutPanel1 = new System.Windows.Forms.FlowLayoutPanel(); this.tabPage_Ws = new System.Windows.Forms.TabPage(); this.flowLayoutPanel2 = new System.Windows.Forms.FlowLayoutPanel(); + this.tabPage_Log = new System.Windows.Forms.TabPage(); + this.flowLayoutPanel3 = new System.Windows.Forms.FlowLayoutPanel(); this.label1 = new System.Windows.Forms.Label(); this.list_roomCaches = new System.Windows.Forms.ListBox(); this.cbx_enableProxy = new System.Windows.Forms.CheckBox(); @@ -43,20 +46,18 @@ this.panel2 = new System.Windows.Forms.Panel(); this.cbx_barrageLog = new System.Windows.Forms.CheckBox(); this.panel3 = new System.Windows.Forms.Panel(); + this.txb_wsaddr = new System.Windows.Forms.TextBox(); this.btn_updateUpProxy = new System.Windows.Forms.Button(); this.txb_upstreamProxy = new System.Windows.Forms.TextBox(); this.label5 = new System.Windows.Forms.Label(); this.label4 = new System.Windows.Forms.Label(); - this.txb_wsaddr = new System.Windows.Forms.TextBox(); - this.tabPage_Log = new System.Windows.Forms.TabPage(); - this.flowLayoutPanel3 = new System.Windows.Forms.FlowLayoutPanel(); this.panel1.SuspendLayout(); this.tab_filters.SuspendLayout(); this.tabPage_Console.SuspendLayout(); this.tabPage_Ws.SuspendLayout(); + this.tabPage_Log.SuspendLayout(); this.panel2.SuspendLayout(); this.panel3.SuspendLayout(); - this.tabPage_Log.SuspendLayout(); this.SuspendLayout(); // // rich_output @@ -115,7 +116,7 @@ // tabPage_Console // this.tabPage_Console.Controls.Add(this.flowLayoutPanel1); - this.tabPage_Console.Font = new System.Drawing.Font("SimSun", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134))); + this.tabPage_Console.Font = new System.Drawing.Font("宋体", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(134))); this.tabPage_Console.Location = new System.Drawing.Point(22, 4); this.tabPage_Console.Name = "tabPage_Console"; this.tabPage_Console.Padding = new System.Windows.Forms.Padding(3); @@ -153,6 +154,27 @@ this.flowLayoutPanel2.Size = new System.Drawing.Size(57, 257); this.flowLayoutPanel2.TabIndex = 6; // + // tabPage_Log + // + this.tabPage_Log.Controls.Add(this.flowLayoutPanel3); + this.tabPage_Log.Location = new System.Drawing.Point(22, 4); + this.tabPage_Log.Name = "tabPage_Log"; + this.tabPage_Log.Size = new System.Drawing.Size(63, 263); + this.tabPage_Log.TabIndex = 2; + this.tabPage_Log.Text = "日志 "; + this.tabPage_Log.UseVisualStyleBackColor = true; + // + // flowLayoutPanel3 + // + this.flowLayoutPanel3.BackColor = System.Drawing.SystemColors.Control; + this.flowLayoutPanel3.Dock = System.Windows.Forms.DockStyle.Fill; + this.flowLayoutPanel3.Location = new System.Drawing.Point(0, 0); + this.flowLayoutPanel3.Margin = new System.Windows.Forms.Padding(2); + this.flowLayoutPanel3.Name = "flowLayoutPanel3"; + this.flowLayoutPanel3.Padding = new System.Windows.Forms.Padding(0, 8, 0, 0); + this.flowLayoutPanel3.Size = new System.Drawing.Size(63, 263); + this.flowLayoutPanel3.TabIndex = 7; + // // label1 // this.label1.AutoSize = true; @@ -246,6 +268,16 @@ this.panel3.Size = new System.Drawing.Size(429, 159); this.panel3.TabIndex = 13; // + // txb_wsaddr + // + this.txb_wsaddr.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.txb_wsaddr.Location = new System.Drawing.Point(85, 14); + this.txb_wsaddr.Margin = new System.Windows.Forms.Padding(2); + this.txb_wsaddr.Name = "txb_wsaddr"; + this.txb_wsaddr.ReadOnly = true; + this.txb_wsaddr.Size = new System.Drawing.Size(164, 21); + this.txb_wsaddr.TabIndex = 16; + // // btn_updateUpProxy // this.btn_updateUpProxy.Location = new System.Drawing.Point(254, 48); @@ -286,37 +318,6 @@ this.label4.TabIndex = 10; this.label4.Text = "WS服务端口:"; // - // txb_wsaddr - // - this.txb_wsaddr.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - this.txb_wsaddr.Location = new System.Drawing.Point(85, 14); - this.txb_wsaddr.Margin = new System.Windows.Forms.Padding(2); - this.txb_wsaddr.Name = "txb_wsaddr"; - this.txb_wsaddr.ReadOnly = true; - this.txb_wsaddr.Size = new System.Drawing.Size(164, 21); - this.txb_wsaddr.TabIndex = 16; - // - // tabPage_Log - // - this.tabPage_Log.Controls.Add(this.flowLayoutPanel3); - this.tabPage_Log.Location = new System.Drawing.Point(22, 4); - this.tabPage_Log.Name = "tabPage_Log"; - this.tabPage_Log.Size = new System.Drawing.Size(63, 263); - this.tabPage_Log.TabIndex = 2; - this.tabPage_Log.Text = "日志 "; - this.tabPage_Log.UseVisualStyleBackColor = true; - // - // flowLayoutPanel3 - // - this.flowLayoutPanel3.BackColor = System.Drawing.SystemColors.Control; - this.flowLayoutPanel3.Dock = System.Windows.Forms.DockStyle.Fill; - this.flowLayoutPanel3.Location = new System.Drawing.Point(0, 0); - this.flowLayoutPanel3.Margin = new System.Windows.Forms.Padding(2); - this.flowLayoutPanel3.Name = "flowLayoutPanel3"; - this.flowLayoutPanel3.Padding = new System.Windows.Forms.Padding(0, 8, 0, 0); - this.flowLayoutPanel3.Size = new System.Drawing.Size(63, 263); - this.flowLayoutPanel3.TabIndex = 7; - // // FormView // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); @@ -335,11 +336,11 @@ this.tab_filters.ResumeLayout(false); this.tabPage_Console.ResumeLayout(false); this.tabPage_Ws.ResumeLayout(false); + this.tabPage_Log.ResumeLayout(false); this.panel2.ResumeLayout(false); this.panel2.PerformLayout(); this.panel3.ResumeLayout(false); this.panel3.PerformLayout(); - this.tabPage_Log.ResumeLayout(false); this.ResumeLayout(false); this.PerformLayout(); diff --git a/BarrageGrab/Forms/FormView.cs b/BarrageGrab/Forms/FormView.cs index 0c400a8..095e5d7 100644 --- a/BarrageGrab/Forms/FormView.cs +++ b/BarrageGrab/Forms/FormView.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; +using System.IO; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; @@ -13,6 +14,7 @@ using BarrageGrab.Forms; using BarrageGrab.Forms.Models; using BarrageGrab.Modles.JsonEntity; using BarrageGrab.Proxy; +using Newtonsoft.Json; namespace BarrageGrab { @@ -24,14 +26,85 @@ namespace BarrageGrab WssBarrageGrab grab = AppRuntime.WsServer.Grab; ISystemProxy proxy = AppRuntime.WsServer.Grab.Proxy; + private Panel testPanel; + private ComboBox cmbPlayers; + private ComboBox cmbEventType; + private PictureBox picAvatar; + private TextBox txtPlayerName; + private Label lblPlayerid; + private Button btnGeneratePlayers; + private Panel inputPanel; + private Label lblDynamic1, lblDynamic2; + private TextBox txtDynamic1, txtDynamic2; + private Button btnSendTest; + private Label lblStatus; + public FormView() { InitializeComponent(); barServer.OnPrint += WssService_OnPrint; barServer.Grab.Proxy.OnProxyStatus += Proxy_OnProxyStatus; AppRuntime.RoomCaches.OnCache += RoomCaches_OnCache; + + // 扩大form和panel2高度 + this.ClientSize = new Size(687, 650); + this.panel2.Height = 390; + this.panel1.Top = 392; + this.panel1.Height = this.ClientSize.Height - this.panel1.Top - 20; + + // 固定panel3高度,不让它随panel2拉伸 + this.panel3.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + this.panel3.Height = 159; + + BuildTestPanel(); + + // 游戏配置按钮 + var btnConfig = new Button + { + Text = "游戏配置", + Width = 100, + Left = 10, + Top = 90 + }; + btnConfig.Click += (s, e) => { using (var f = new Forms.ConfigEditorForm()) f.ShowDialog(this); }; + this.panel3.Controls.Add(btnConfig); + + // Settings button + var btnSettings = new Button + { + Text = "设置", + Width = 60, + Left = 120, + Top = 90 + }; + btnSettings.Click += (s, e) => { using (var f = new Forms.SettingsForm()) f.ShowDialog(this); }; + this.panel3.Controls.Add(btnSettings); + + + // 打开游戏按钮 + var btnLaunchGame = new Button + { + Text = "打开游戏", + Width = 80, + Left = 190, + Top = 90 + }; + btnLaunchGame.Click += (s, e) => { Program.LaunchGame(); }; + this.panel3.Controls.Add(btnLaunchGame); + + // 重新打开游戏按钮 + var btnRestartGame = new Button + { + Text = "重启游戏", + Width = 80, + Left = 280, + Top = 90 + }; + btnRestartGame.Click += (s, e) => { Program.RestartGame(); }; + this.panel3.Controls.Add(btnRestartGame); } + private void InitTabPages() { var msgTypes = GetMsgTypes(); @@ -235,5 +308,351 @@ namespace BarrageGrab AppSetting.Current.BarrageLog = checker.Checked; AppSetting.Current.Save(); } + + // ==================== 测试面板 ==================== + + private readonly string DefaultAvatarUrl = "https://aliyun_id_photo_bucket.oss.aliyuncs.com/default_handsome_intl.jpg"; + private readonly List<(long Id, string Name)> _testPlayers = new List<(long Id, string Name)>(); + private static readonly string[] SampleNames = { + "小明", "小红", "玩家001", "铁柱", "翠花", "老六", "大佬666", + "战神", "小可爱", "路人甲", "氪金玩家", "欧皇", "非酋", "主播铁粉", + "水友123", "弹幕刷屏王", "白嫖党", "榜一大哥", "守塔小兵", "运营总监" + }; + + private void BuildTestPanel() + { + // 测试面板标题 + int yBase = 195; + var lblTitle = new Label + { + Text = "📡 WebSocket 模拟测试", + Location = new Point(250, yBase), + Size = new Size(200, 18), + Font = new Font("微软雅黑", 10, FontStyle.Bold), + ForeColor = Color.DarkBlue + }; + panel2.Controls.Add(lblTitle); + + // ── 生成按钮 ── + btnGeneratePlayers = new Button + { + Text = "随机生成玩家", + Location = new Point(250, yBase + 20), + Size = new Size(130, 24), + BackColor = Color.LightSteelBlue, + UseVisualStyleBackColor = true + }; + btnGeneratePlayers.Click += BtnGeneratePlayers_Click; + panel2.Controls.Add(btnGeneratePlayers); + + // ── 玩家下拉(含头像) ── + cmbPlayers = new ComboBox + { + Location = new Point(250, yBase + 46), + Size = new Size(310, 22), + DropDownStyle = ComboBoxStyle.DropDownList, + DropDownWidth = 400 + }; + cmbPlayers.SelectedIndexChanged += CmbPlayers_SelectedIndexChanged; + panel2.Controls.Add(cmbPlayers); + + lblPlayerid = new Label + { + Text = "ID: --", + Location = new Point(565, yBase + 48), + Size = new Size(110, 18), + ForeColor = Color.Gray + }; + panel2.Controls.Add(lblPlayerid); + + // ── 事件类型下拉 ── + var lblEvent = new Label { Text = "事件:", Location = new Point(250, yBase + 74), Size = new Size(40, 20) }; + panel2.Controls.Add(lblEvent); + + cmbEventType = new ComboBox + { + Location = new Point(290, yBase + 72), + Size = new Size(130, 22), + DropDownStyle = ComboBoxStyle.DropDownList + }; + cmbEventType.Items.AddRange(new object[] { "弹幕消息", "点赞消息", "礼物消息", "关注消息", "进直播间", "直播间分享", "粉丝团消息" }); + cmbEventType.SelectedIndex = 0; + cmbEventType.SelectedIndexChanged += CmbEventType_SelectedIndexChanged; + panel2.Controls.Add(cmbEventType); + + // ── 发送按钮 ── + btnSendTest = new Button + { + Text = "发送", + Location = new Point(430, yBase + 70), + Size = new Size(130, 26), + BackColor = Color.Orange, + Font = new Font("微软雅黑", 9, FontStyle.Bold), + UseVisualStyleBackColor = true + }; + btnSendTest.Click += BtnSendTest_Click; + panel2.Controls.Add(btnSendTest); + + // ── 动态输入面板 ── + inputPanel = new Panel + { + Location = new Point(248, yBase + 100), + Size = new Size(430, 50), + BorderStyle = BorderStyle.FixedSingle + }; + panel2.Controls.Add(inputPanel); + + lblDynamic1 = new Label { Location = new Point(8, 8), Size = new Size(50, 18), Text = "内容:" }; + inputPanel.Controls.Add(lblDynamic1); + txtDynamic1 = new TextBox { Location = new Point(60, 6), Size = new Size(200, 21), Text = "测试弹幕666" }; + inputPanel.Controls.Add(txtDynamic1); + + lblDynamic2 = new Label { Location = new Point(8, 30), Size = new Size(50, 18), Text = "" }; + inputPanel.Controls.Add(lblDynamic2); + txtDynamic2 = new TextBox { Location = new Point(60, 28), Size = new Size(80, 21), Text = "", Visible = false }; + inputPanel.Controls.Add(txtDynamic2); + + // ── 状态 ── + lblStatus = new Label + { + Location = new Point(250, yBase + 155), + Size = new Size(425, 18), + ForeColor = Color.DimGray + }; + panel2.Controls.Add(lblStatus); + + // 初始刷新 + RefreshInputPanel(); + } + + private void BtnGeneratePlayers_Click(object sender, EventArgs e) + { + _testPlayers.Clear(); + cmbPlayers.Items.Clear(); + + var rnd = new Random(); + // 生成28个完全随机玩家 + for (int i = 0; i < 28; i++) + { + long id = 10000 + rnd.Next(1, 99999); + string name = SampleNames[rnd.Next(SampleNames.Length)]; + if (rnd.Next(2) == 0) name += rnd.Next(10, 999).ToString(); + _testPlayers.Add((id, name)); + cmbPlayers.Items.Add($"{name} (ID:{id})"); + } + + cmbPlayers.SelectedIndex = 0; + lblStatus.Text = $"已生成 {_testPlayers.Count} 个测试玩家"; + } + + private void CmbPlayers_SelectedIndexChanged(object sender, EventArgs e) + { + if (cmbPlayers.SelectedIndex >= 0 && cmbPlayers.SelectedIndex < _testPlayers.Count) + { + var (id, name) = _testPlayers[cmbPlayers.SelectedIndex]; + lblPlayerid.Text = $"ID: {id}"; + } + } + + private void CmbEventType_SelectedIndexChanged(object sender, EventArgs e) + { + RefreshInputPanel(); + } + + private void RefreshInputPanel() + { + string evt = cmbEventType.SelectedItem?.ToString() ?? ""; + switch (evt) + { + case "弹幕消息": + lblDynamic1.Text = "内容:"; + txtDynamic1.Visible = true; + txtDynamic1.Text = "测试弹幕666"; + lblDynamic2.Text = ""; + txtDynamic2.Visible = false; + break; + case "点赞消息": + lblDynamic1.Text = "点赞数:"; + txtDynamic1.Visible = true; + txtDynamic1.Text = "10"; + lblDynamic2.Text = ""; + txtDynamic2.Visible = false; + break; + case "礼物消息": + lblDynamic1.Text = "礼物名:"; + txtDynamic1.Visible = true; + txtDynamic1.Text = "小心心"; + lblDynamic2.Text = "数量:"; + txtDynamic2.Visible = true; + txtDynamic2.Text = "1"; + break; + case "直播间分享": + lblDynamic1.Text = "次数:"; + txtDynamic1.Visible = true; + txtDynamic1.Text = "1"; + lblDynamic2.Text = ""; + txtDynamic2.Visible = false; + break; + default: // 关注/进房/粉丝团 + lblDynamic1.Text = "无额外参数"; + txtDynamic1.Visible = false; + lblDynamic2.Text = ""; + txtDynamic2.Visible = false; + break; + } + } + + private void BtnSendTest_Click(object sender, EventArgs e) + { + try + { + if (cmbPlayers.SelectedIndex < 0 || cmbPlayers.SelectedIndex >= _testPlayers.Count) + { + lblStatus.Text = "❌ 请先选择玩家"; + return; + } + + var (pid, pname) = _testPlayers[cmbPlayers.SelectedIndex]; + string evt = cmbEventType.SelectedItem?.ToString() ?? "弹幕消息"; + + MsgUser user = new MsgUser + { + Id = pid, + Nickname = pname, + HeadImgUrl = DefaultAvatarUrl, + DisplayId = pid.ToString(), + Level = 10, + Gender = 1 + }; + + BarrageMsgPack pack = null; + string desc = ""; + + switch (evt) + { + case "弹幕消息": + { + var msg = new Msg + { + MsgId = DateTime.Now.Ticks, + User = user, + Content = txtDynamic1.Text, + RoomId = "123456" + }; + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.弹幕消息, "test"); + desc = $"弹幕: {txtDynamic1.Text}"; + } + break; + + case "点赞消息": + { + long cnt = long.TryParse(txtDynamic1.Text, out var c) ? c : 1; + var msg = new LikeMsg + { + MsgId = DateTime.Now.Ticks, + User = user, + Count = cnt, + Total = cnt + 100, + RoomId = "123456" + }; + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.点赞消息, "test"); + desc = $"点赞 x{cnt}"; + } + break; + + case "礼物消息": + { + long cnt = long.TryParse(txtDynamic2.Text, out var gc) ? gc : 1; + var msg = new GiftMsg + { + MsgId = DateTime.Now.Ticks, + User = user, + GiftName = txtDynamic1.Text, + GiftCount = cnt, + GiftId = 100, + DiamondCount = 1, + RoomId = "123456" + }; + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.礼物消息, "test"); + desc = $"礼物: {txtDynamic1.Text} x{cnt}"; + } + break; + + case "关注消息": + { + var msg = new Msg + { + MsgId = DateTime.Now.Ticks, + User = user, + Content = "关注了主播", + RoomId = "123456" + }; + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.关注消息, "test"); + desc = "关注主播"; + } + break; + + case "进直播间": + { + var msg = new MemberMessage + { + MsgId = DateTime.Now.Ticks, + User = user, + CurrentCount = 999, + RoomId = "123456" + }; + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.进直播间, "test"); + desc = "进入直播间"; + } + break; + + case "直播间分享": + { + long cnt = long.TryParse(txtDynamic1.Text, out var sc) ? sc : 1; + var msg = new ShareMessage + { + MsgId = DateTime.Now.Ticks, + User = user, + ShareType = BarrageGrab.Modles.JsonEntity.ShareType.微信, + RoomId = "123456" + }; + desc = $"分享 x{cnt}"; + // 分享用次数送多次 + for (int i = 0; i < cnt; i++) + { + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.直播间分享, "test"); + barServer.Broadcast(pack); + } + lblStatus.Text = $"✅ 已发送: {pname}(ID:{pid}) → {desc}"; + return; + } + + case "粉丝团消息": + { + var msg = new FansclubMsg + { + MsgId = DateTime.Now.Ticks, + User = user, + Type = 2, // 加入 + Level = 1, + RoomId = "123456" + }; + pack = new BarrageMsgPack(JsonConvert.SerializeObject(msg), PackMsgType.粉丝团消息, "test"); + desc = "加入粉丝团"; + } + break; + } + + if (pack != null) + { + barServer.Broadcast(pack); + lblStatus.Text = $"✅ 已发送: {pname}(ID:{pid}) → {desc}"; + } + } + catch (Exception ex) + { + lblStatus.Text = $"❌ 发送失败: {ex.Message}"; + } + } } } diff --git a/BarrageGrab/Forms/SettingsForm.cs b/BarrageGrab/Forms/SettingsForm.cs new file mode 100644 index 0000000..0fe394f --- /dev/null +++ b/BarrageGrab/Forms/SettingsForm.cs @@ -0,0 +1,630 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Windows.Forms; +using Newtonsoft.Json.Linq; + +namespace BarrageGrab.Forms +{ + /// + /// 完整设置界面 —— 可视化编辑 AppConfig.json + 游戏集成 + /// 配置项分8个Tab: 常规/网络/弹幕过滤/轮询/进程域名/直播伴侣/串口/游戏启动项 + /// 游戏启动项 Tab 管理启动项 + 5个配置表路径 + /// + public class SettingsForm : Form + { + private TabControl tabMain; + private Label lblStatus; + + private string ConfigPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "appConfig.json"); + private string GameSettingsPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "game_settings.json"); + + // 常规 + private ComboBox cmbPlatform; + private CheckBox cbHideConsole, cbShowWindow; + // 网络 + private NumericUpDown nudWsPort, nudProxyPort; + private CheckBox cbListenAny, cbProxyEnabled; + private TextBox txtUpstreamProxy; + // 弹幕过滤 + private CheckBox cbPrintBarrage, cbBarrageLog; + private CheckedListBox clbPrintFilter, clbPushFilter, clbLogFilter; + // 轮询 + private CheckBox cbForcePolling; + private NumericUpDown nudPollingInterval; + // 进程/域名 + private TextBox txtProcessFilter, txtHostNameList, txtWebRoomIds; + private CheckBox cbFilterHostName; + // 直播伴侣 + private TextBox txtLiveCompanPath; + private CheckBox cbLiveCompanHook, cbAutoPause; + // 串口 + private TextBox txtComPort, txtComBaud; + // 游戏启动项 + private TextBox txtGameExePath, txtGameWorkDir; + private CheckBox cbAutoLaunchGame; + // 游戏配置表路径 + private TextBox txtConfigDir; + private TextBox txtEffectFile, txtMacroFile, txtGiftFile, txtCmdFile, txtFkeyFile; + + private readonly Tuple[] MsgTypeOptions = { + Tuple.Create(1, "1-弹幕消息"), + Tuple.Create(2, "2-点赞消息"), + Tuple.Create(3, "3-进入直播间"), + Tuple.Create(4, "4-关注消息"), + Tuple.Create(5, "5-礼物消息"), + Tuple.Create(6, "6-统计消息"), + Tuple.Create(7, "7-粉丝团消息"), + Tuple.Create(8, "8-直播间分享"), + Tuple.Create(9, "9-下播"), + }; + + // 默认值 + private const string DefaultGameExe = @"..\Fantasy.Map.Simulator\FantasyMapSimulator.exe"; + private const string DefaultGameDir = @"..\Fantasy.Map.Simulator"; + private const string DefaultConfigDir = @"..\Fantasy.Map.Simulator\BepInEx\config"; + + public SettingsForm() + { + this.Text = "弹幕插件设置"; + this.ClientSize = new Size(620, 620); + this.StartPosition = FormStartPosition.CenterParent; + this.FormBorderStyle = FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + BuildUI(); + LoadAll(); + } + + void BuildUI() + { + tabMain = new TabControl { Dock = DockStyle.Top, Height = 540, Name = "tabMain" }; + this.Controls.Add(tabMain); + + var pnlBottom = new Panel { Dock = DockStyle.Bottom, Height = 50 }; + this.Controls.Add(pnlBottom); + + var btnSave = new Button { Text = "保存并应用", Width = 110, Height = 30, Left = 140, Top = 10, BackColor = Color.LightGreen }; + btnSave.Click += (s, e) => SaveAll(); + pnlBottom.Controls.Add(btnSave); + + var btnCancel = new Button { Text = "取消", Width = 80, Height = 30, Left = 260, Top = 10 }; + btnCancel.Click += (s, e) => this.Close(); + pnlBottom.Controls.Add(btnCancel); + + var btnReload = new Button { Text = "重新加载", Width = 90, Height = 30, Left = 350, Top = 10 }; + btnReload.Click += (s, e) => LoadAll(); + pnlBottom.Controls.Add(btnReload); + + lblStatus = new Label { AutoSize = true, Left = 450, Top = 18, ForeColor = Color.DimGray, Text = "" }; + pnlBottom.Controls.Add(lblStatus); + + BuildGeneralTab(); + BuildNetworkTab(); + BuildBarrageFilterTab(); + BuildPollingTab(); + BuildProcessDomainTab(); + BuildLiveCompanionTab(); + BuildComPortTab(); + BuildGameTab(); + } + + void BuildGeneralTab() + { + var page = new TabPage("常规"); + page.Controls.Add(new Label { Text = "直播平台:", Left = 20, Top = 20, AutoSize = true }); + cmbPlatform = new ComboBox { Left = 120, Top = 17, Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; + cmbPlatform.Items.AddRange(new object[] { "douyin", "kuaishou" }); + page.Controls.Add(cmbPlatform); + + cbHideConsole = new CheckBox { Text = "隐藏控制台", Left = 20, Top = 60, AutoSize = true }; + page.Controls.Add(cbHideConsole); + + cbShowWindow = new CheckBox { Text = "显示主窗体", Left = 20, Top = 90, AutoSize = true }; + page.Controls.Add(cbShowWindow); + + page.Controls.Add(new Label { Text = "提示: 平台切换需重启程序生效", Left = 20, Top = 150, AutoSize = true, ForeColor = Color.Gray }); + tabMain.TabPages.Add(page); + } + + void BuildNetworkTab() + { + var page = new TabPage("网络"); + page.Controls.Add(new Label { Text = "WebSocket 端口:", Left = 20, Top = 20, AutoSize = true }); + nudWsPort = new NumericUpDown { Left = 150, Top = 17, Width = 80, Minimum = 1, Maximum = 65535 }; + page.Controls.Add(nudWsPort); + + cbListenAny = new CheckBox { Text = "监听任意IP(0.0.0.0)", Left = 20, Top = 55, AutoSize = true }; + page.Controls.Add(cbListenAny); + + page.Controls.Add(new Label { Text = "系统代理端口:", Left = 20, Top = 90, AutoSize = true }); + nudProxyPort = new NumericUpDown { Left = 150, Top = 87, Width = 80, Minimum = 1, Maximum = 65535 }; + page.Controls.Add(nudProxyPort); + + cbProxyEnabled = new CheckBox { Text = "启用系统代理", Left = 20, Top = 125, AutoSize = true }; + page.Controls.Add(cbProxyEnabled); + + page.Controls.Add(new Label { Text = "上游代理地址:", Left = 20, Top = 160, AutoSize = true }); + txtUpstreamProxy = new TextBox { Left = 150, Top = 157, Width = 250 }; + page.Controls.Add(txtUpstreamProxy); + page.Controls.Add(new Label { Text = "(例如: 127.0.0.1:11223, 不要带http://)", Left = 150, Top = 183, AutoSize = true, ForeColor = Color.Gray }); + tabMain.TabPages.Add(page); + } + + void BuildBarrageFilterTab() + { + var page = new TabPage("弹幕过滤"); + cbPrintBarrage = new CheckBox { Text = "控制台打印弹幕", Left = 10, Top = 10, AutoSize = true }; + page.Controls.Add(cbPrintBarrage); + cbBarrageLog = new CheckBox { Text = "弹幕写入日志文件", Left = 200, Top = 10, AutoSize = true }; + page.Controls.Add(cbBarrageLog); + + page.Controls.Add(new Label { Text = "打印过滤器:", Left = 10, Top = 40, AutoSize = true }); + clbPrintFilter = CreateFilterListBox(); + clbPrintFilter.Location = new Point(10, 60); + page.Controls.Add(clbPrintFilter); + + page.Controls.Add(new Label { Text = "推送过滤器:", Left = 220, Top = 40, AutoSize = true }); + clbPushFilter = CreateFilterListBox(); + clbPushFilter.Location = new Point(220, 60); + page.Controls.Add(clbPushFilter); + + page.Controls.Add(new Label { Text = "日志过滤器:", Left = 430, Top = 40, AutoSize = true }); + clbLogFilter = CreateFilterListBox(); + clbLogFilter.Location = new Point(430, 60); + page.Controls.Add(clbLogFilter); + tabMain.TabPages.Add(page); + } + + CheckedListBox CreateFilterListBox() + { + var clb = new CheckedListBox { Width = 200, Height = 200 }; + foreach (var opt in MsgTypeOptions) clb.Items.Add(opt.Item2, false); + return clb; + } + + void BuildPollingTab() + { + var page = new TabPage("轮询"); + cbForcePolling = new CheckBox { Text = "强制启用轮询模式获取弹幕", Left = 20, Top = 20, AutoSize = true }; + page.Controls.Add(cbForcePolling); + page.Controls.Add(new Label { Text = "轮询间隔(ms):", Left = 20, Top = 60, AutoSize = true }); + nudPollingInterval = new NumericUpDown { Left = 150, Top = 57, Width = 80, Minimum = 500, Maximum = 60000, Increment = 500 }; + page.Controls.Add(nudPollingInterval); + page.Controls.Add(new Label { Text = "轮询模式更稳定但响应较慢", Left = 20, Top = 100, AutoSize = true, ForeColor = Color.Gray }); + page.Controls.Add(new Label { Text = "不建议小于1000ms", Left = 20, Top = 120, AutoSize = true, ForeColor = Color.Gray }); + tabMain.TabPages.Add(page); + } + + void BuildProcessDomainTab() + { + var page = new TabPage("进程/域名"); + page.Controls.Add(new Label { Text = "进程过滤器(逗号分隔):", Left = 20, Top = 20, AutoSize = true }); + txtProcessFilter = new TextBox { Left = 20, Top = 40, Width = 550, Multiline = true, Height = 40 }; + page.Controls.Add(txtProcessFilter); + page.Controls.Add(new Label { Text = "只监听这些进程的弹幕数据", Left = 20, Top = 85, AutoSize = true, ForeColor = Color.Gray }); + + cbFilterHostName = new CheckBox { Text = "启用域名过滤(降低CPU占用)", Left = 20, Top = 115, AutoSize = true }; + page.Controls.Add(cbFilterHostName); + + page.Controls.Add(new Label { Text = "域名白名单(逗号分隔):", Left = 20, Top = 145, AutoSize = true }); + txtHostNameList = new TextBox { Left = 20, Top = 165, Width = 550 }; + page.Controls.Add(txtHostNameList); + + page.Controls.Add(new Label { Text = "Web房间ID过滤(逗号分隔):", Left = 20, Top = 195, AutoSize = true }); + txtWebRoomIds = new TextBox { Left = 20, Top = 215, Width = 550 }; + page.Controls.Add(txtWebRoomIds); + page.Controls.Add(new Label { Text = "只监听指定房间,直播伴侣不支持", Left = 20, Top = 240, AutoSize = true, ForeColor = Color.Gray }); + tabMain.TabPages.Add(page); + } + + void BuildLiveCompanionTab() + { + var page = new TabPage("直播伴侣"); + page.Controls.Add(new Label { Text = "直播伴侣exe路径:", Left = 20, Top = 20, AutoSize = true }); + txtLiveCompanPath = new TextBox { Left = 20, Top = 40, Width = 450 }; + page.Controls.Add(txtLiveCompanPath); + var btnBrowse = new Button { Text = "浏览...", Left = 480, Top = 39, Width = 70 }; + btnBrowse.Click += (s, e) => + { + using (var dlg = new OpenFileDialog { Filter = "EXE文件|*.exe|所有文件|*.*" }) + if (dlg.ShowDialog() == DialogResult.OK) txtLiveCompanPath.Text = dlg.FileName; + }; + page.Controls.Add(btnBrowse); + page.Controls.Add(new Label { Text = "(留空则程序自动查找)", Left = 20, Top = 65, AutoSize = true, ForeColor = Color.Gray }); + + cbLiveCompanHook = new CheckBox { Text = "Hook直播伴侣代理", Left = 20, Top = 95, AutoSize = true }; + page.Controls.Add(cbLiveCompanHook); + cbAutoPause = new CheckBox { Text = "进入直播间自动暂停", Left = 20, Top = 125, AutoSize = true }; + page.Controls.Add(cbAutoPause); + tabMain.TabPages.Add(page); + } + + void BuildComPortTab() + { + var page = new TabPage("串口"); + page.Controls.Add(new Label { Text = "COM端口:", Left = 20, Top = 20, AutoSize = true }); + txtComPort = new TextBox { Left = 120, Top = 17, Width = 100 }; + page.Controls.Add(txtComPort); + page.Controls.Add(new Label { Text = "例如: COM1", Left = 230, Top = 20, AutoSize = true, ForeColor = Color.Gray }); + page.Controls.Add(new Label { Text = "波特率:", Left = 20, Top = 55, AutoSize = true }); + txtComBaud = new TextBox { Left = 120, Top = 52, Width = 100 }; + page.Controls.Add(txtComBaud); + page.Controls.Add(new Label { Text = "例如: 9600", Left = 230, Top = 55, AutoSize = true, ForeColor = Color.Gray }); + page.Controls.Add(new Label { Text = "留空则不启用串口抄送服务", Left = 20, Top = 95, AutoSize = true, ForeColor = Color.Gray }); + tabMain.TabPages.Add(page); + } + + void BuildGameTab() + { + var page = new TabPage("游戏启动项"); + int y = 15; + + // === 启动项 === + page.Controls.Add(new Label { Text = "── 启动项 ──", Left = 20, Top = y, AutoSize = true, ForeColor = Color.SteelBlue, Font = new Font(Font, FontStyle.Bold) }); + y += 25; + + page.Controls.Add(new Label { Text = "游戏exe路径:", Left = 20, Top = y, AutoSize = true }); + txtGameExePath = new TextBox { Left = 120, Top = y - 3, Width = 380 }; + page.Controls.Add(txtGameExePath); + var btnBrowse1 = new Button { Text = "浏览...", Left = 510, Top = y - 4, Width = 70 }; + btnBrowse1.Click += (s, e) => + { + using (var dlg = new OpenFileDialog { Filter = "EXE文件|*.exe|所有文件|*.*" }) + if (dlg.ShowDialog() == DialogResult.OK) + { + txtGameExePath.Text = ToRelativePath(dlg.FileName); + var dir = Path.GetDirectoryName(dlg.FileName); + if (!string.IsNullOrEmpty(dir)) txtGameWorkDir.Text = ToRelativePath(dir); + } + }; + page.Controls.Add(btnBrowse1); + y += 27; + + page.Controls.Add(new Label { Text = "工作目录:", Left = 20, Top = y, AutoSize = true }); + txtGameWorkDir = new TextBox { Left = 120, Top = y - 3, Width = 380 }; + page.Controls.Add(txtGameWorkDir); + y += 27; + + cbAutoLaunchGame = new CheckBox { Text = "启动时自动打开游戏", Left = 20, Top = y, AutoSize = true }; + page.Controls.Add(cbAutoLaunchGame); + y += 30; + + // === 配置表路径 === + page.Controls.Add(new Label { Text = "── 配置表路径 ──", Left = 20, Top = y, AutoSize = true, ForeColor = Color.SteelBlue, Font = new Font(Font, FontStyle.Bold) }); + y += 25; + + page.Controls.Add(new Label { Text = "配置表目录:", Left = 20, Top = y, AutoSize = true }); + txtConfigDir = new TextBox { Left = 120, Top = y - 3, Width = 380 }; + page.Controls.Add(txtConfigDir); + var btnBrowse2 = new Button { Text = "浏览...", Left = 510, Top = y - 4, Width = 70 }; + btnBrowse2.Click += (s, e) => + { + using (var dlg = new FolderBrowserDialog()) + if (dlg.ShowDialog() == DialogResult.OK) txtConfigDir.Text = ToRelativePath(dlg.SelectedPath); + }; + page.Controls.Add(btnBrowse2); + y += 30; + + // 5个配置表文件名 + txtEffectFile = AddConfigFileRow(page, ref y, "基础效果(effect):", "effect_config.txt"); + txtMacroFile = AddConfigFileRow(page, ref y, "组合宏(macro):", "macro_config.txt"); + txtGiftFile = AddConfigFileRow(page, ref y, "礼物事件(gift):", "gift_config.txt"); + txtCmdFile = AddConfigFileRow(page, ref y, "弹幕指令(command):", "command_config.txt"); + txtFkeyFile = AddConfigFileRow(page, ref y, "F键映射(fkey):", "fkey_config.txt"); + y += 5; + + // === 功能按钮 === + var btnOpenGameConfig = new Button { Text = "打开配置编辑器", Left = 20, Top = y, Width = 130, Height = 30 }; + btnOpenGameConfig.Click += (s, e) => { using (var f = new ConfigEditorForm()) f.ShowDialog(this); }; + page.Controls.Add(btnOpenGameConfig); + + var btnOpenConfigDir = new Button { Text = "打开配置目录", Left = 160, Top = y, Width = 110 }; + btnOpenConfigDir.Click += (s, e) => + { + var dir = Program.ResolveFullPath(txtConfigDir.Text.Trim()); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) + System.Diagnostics.Process.Start("explorer.exe", dir); + else + MessageBox.Show($"配置目录不存在:\n{dir}", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); + }; + page.Controls.Add(btnOpenConfigDir); + + var btnResetDefaults = new Button { Text = "恢复默认", Left = 280, Top = y, Width = 80 }; + btnResetDefaults.Click += (s, e) => ResetGameDefaults(); + page.Controls.Add(btnResetDefaults); + + tabMain.TabPages.Add(page); + } + + TextBox AddConfigFileRow(TabPage page, ref int y, string label, string defaultName) + { + page.Controls.Add(new Label { Text = label, Left = 30, Top = y, AutoSize = true }); + var txt = new TextBox { Left = 180, Top = y - 3, Width = 200, Text = defaultName }; + page.Controls.Add(txt); + y += 24; + return txt; + } + + /// 将绝对路径转为相对插件目录的相对路径;若转换失败或已是相对路径则原样返回。 + static string ToRelativePath(string absPath) + { + if (string.IsNullOrWhiteSpace(absPath)) return absPath; + if (!Path.IsPathRooted(absPath)) return absPath; // 已是相对路径 + try + { + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var fullBase = Path.GetFullPath(baseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var fullAbs = Path.GetFullPath(absPath); + var baseUri = new Uri(fullBase + Path.DirectorySeparatorChar); + var absUri = new Uri(fullAbs); + var rel = baseUri.MakeRelativeUri(absUri).ToString(); + // Uri 返回的是用 / 分隔的相对路径,统一为 Windows 分隔符 + rel = Uri.UnescapeDataString(rel).Replace('/', Path.DirectorySeparatorChar); + return rel; + } + catch { return absPath; } + } + + void ResetGameDefaults() + { + txtGameExePath.Text = DefaultGameExe; + txtGameWorkDir.Text = DefaultGameDir; + txtConfigDir.Text = DefaultConfigDir; + txtEffectFile.Text = "effect_config.txt"; + txtMacroFile.Text = "macro_config.txt"; + txtGiftFile.Text = "gift_config.txt"; + txtCmdFile.Text = "command_config.txt"; + txtFkeyFile.Text = "fkey_config.txt"; + cbAutoLaunchGame.Checked = true; + } + + // ═══════════════ 加载 ═══════════════ + + void LoadAll() + { + try + { + LoadAppConfig(); + LoadGameSettings(); + lblStatus.Text = "已加载"; + lblStatus.ForeColor = Color.DimGray; + } + catch (Exception ex) + { + lblStatus.Text = $"加载失败: {ex.Message}"; + lblStatus.ForeColor = Color.Red; + } + } + + void LoadAppConfig() + { + if (!File.Exists(ConfigPath)) return; + var config = JObject.Parse(File.ReadAllText(ConfigPath), new JsonLoadSettings { CommentHandling = CommentHandling.Ignore }); + var app = config["app"]; + + var general = app["general"]; + cmbPlatform.SelectedItem = general["platform"]?.Value() ?? "douyin"; + cbHideConsole.Checked = general["hideConsole"]?.Value() ?? false; + cbShowWindow.Checked = general["showWindow"]?.Value() ?? false; + + var proxy = app["network"]["proxy"]; + nudProxyPort.Value = proxy["port"]?.Value() ?? 8827; + cbProxyEnabled.Checked = proxy["enabled"]?.Value() ?? true; + txtUpstreamProxy.Text = proxy["upstreamAddress"]?.Value() ?? ""; + var ws = app["network"]["websocket"]; + nudWsPort.Value = ws["listenPort"]?.Value() ?? 8888; + cbListenAny.Checked = ws["listenAny"]?.Value() ?? true; + + var barrage = app["barrage"]; + cbPrintBarrage.Checked = barrage["printEnabled"]?.Value() ?? true; + cbBarrageLog.Checked = barrage["barrageFileLog"]?.Value() ?? false; + SetFilterChecks(clbPrintFilter, barrage["printFilter"]?.Value() ?? ""); + SetFilterChecks(clbPushFilter, barrage["pushFilter"]?.Value() ?? ""); + SetFilterChecks(clbLogFilter, barrage["logFilter"]?.Value() ?? "1,2,4,5,6,7,8"); + + var polling = barrage["polling"]; + cbForcePolling.Checked = polling["enabled"]?.Value() ?? false; + nudPollingInterval.Value = polling["interval"]?.Value() ?? 3000; + + var filtering = app["filtering"]; + txtProcessFilter.Text = filtering["processFilter"]?.Value() ?? ""; + cbFilterHostName.Checked = filtering["hostNameEnabled"]?.Value() ?? true; + txtHostNameList.Text = filtering["hostNameList"]?.Value() ?? ""; + txtWebRoomIds.Text = filtering["webRoomIds"]?.Value() ?? ""; + + var liveCompanion = app["liveCompanion"]; + txtLiveCompanPath.Text = liveCompanion["path"]?.Value() ?? ""; + cbLiveCompanHook.Checked = liveCompanion["hookEnabled"]?.Value() ?? false; + cbAutoPause.Checked = liveCompanion["autoPause"]?.Value() ?? true; + + var comPortStr = app["comPort"]["config"]?.Value() ?? ""; + if (!string.IsNullOrWhiteSpace(comPortStr)) + { + var parts = comPortStr.Split(':'); + if (parts.Length >= 2) { txtComPort.Text = parts[0].Trim(); txtComBaud.Text = parts[1].Trim(); } + } + } + + void LoadGameSettings() + { + // 默认值 + txtGameExePath.Text = DefaultGameExe; + txtGameWorkDir.Text = DefaultGameDir; + cbAutoLaunchGame.Checked = true; + txtConfigDir.Text = DefaultConfigDir; + txtEffectFile.Text = "effect_config.txt"; + txtMacroFile.Text = "macro_config.txt"; + txtGiftFile.Text = "gift_config.txt"; + txtCmdFile.Text = "command_config.txt"; + txtFkeyFile.Text = "fkey_config.txt"; + + if (File.Exists(GameSettingsPath)) + { + try + { + var json = JObject.Parse(File.ReadAllText(GameSettingsPath)); + txtGameExePath.Text = json["gameExePath"]?.Value() ?? txtGameExePath.Text; + txtGameWorkDir.Text = json["gameWorkDir"]?.Value() ?? txtGameWorkDir.Text; + cbAutoLaunchGame.Checked = json["autoLaunchGame"]?.Value() ?? true; + txtConfigDir.Text = json["configDir"]?.Value() ?? txtConfigDir.Text; + + var cfg = json["configFiles"]; + if (cfg != null) + { + txtEffectFile.Text = cfg["effect"]?.Value() ?? txtEffectFile.Text; + txtMacroFile.Text = cfg["macro"]?.Value() ?? txtMacroFile.Text; + txtGiftFile.Text = cfg["gift"]?.Value() ?? txtGiftFile.Text; + txtCmdFile.Text = cfg["command"]?.Value() ?? txtCmdFile.Text; + txtFkeyFile.Text = cfg["fkey"]?.Value() ?? txtFkeyFile.Text; + } + } + catch { } + } + Program.AutoLaunchGame = cbAutoLaunchGame.Checked; + } + + void SetFilterChecks(CheckedListBox clb, string filterStr) + { + for (int i = 0; i < clb.Items.Count; i++) clb.SetItemChecked(i, false); + if (string.IsNullOrWhiteSpace(filterStr)) return; + var ids = filterStr.Split(',').Select(s => s.Trim()).Where(s => !string.IsNullOrEmpty(s)).ToList(); + for (int i = 0; i < clb.Items.Count; i++) + { + if (ids.Contains(MsgTypeOptions[i].Item1.ToString())) + clb.SetItemChecked(i, true); + } + } + + string GetFilterString(CheckedListBox clb) + { + var ids = new List(); + for (int i = 0; i < clb.Items.Count; i++) + if (clb.GetItemChecked(i)) ids.Add(MsgTypeOptions[i].Item1.ToString()); + return string.Join(",", ids); + } + + // ═══════════════ 保存 ═══════════════ + + void SaveAll() + { + try + { + SaveAppConfig(); + SaveGameSettings(); + AppSetting.Current.LoadFromJson(ConfigPath); + lblStatus.Text = "已保存(部分设置需重启生效)"; + lblStatus.ForeColor = Color.Green; + } + catch (Exception ex) + { + lblStatus.Text = $"保存失败: {ex.Message}"; + lblStatus.ForeColor = Color.Red; + } + } + + void SaveAppConfig() + { + if (!File.Exists(ConfigPath)) + { + var json = new JObject(new JProperty("app", new JObject( + new JProperty("general", new JObject( + new JProperty("hideConsole", cbHideConsole.Checked), + new JProperty("showWindow", cbShowWindow.Checked), + new JProperty("platform", cmbPlatform.SelectedItem?.ToString() ?? "douyin"))), + new JProperty("network", new JObject( + new JProperty("proxy", new JObject( + new JProperty("port", (int)nudProxyPort.Value), + new JProperty("enabled", cbProxyEnabled.Checked), + new JProperty("upstreamAddress", txtUpstreamProxy.Text.Trim()))), + new JProperty("websocket", new JObject( + new JProperty("listenPort", (int)nudWsPort.Value), + new JProperty("listenAny", cbListenAny.Checked))))), + new JProperty("filtering", new JObject( + new JProperty("processFilter", txtProcessFilter.Text.Trim()), + new JProperty("hostNameEnabled", cbFilterHostName.Checked), + new JProperty("hostNameList", txtHostNameList.Text.Trim()), + new JProperty("webRoomIds", txtWebRoomIds.Text.Trim()))), + new JProperty("barrage", new JObject( + new JProperty("printEnabled", cbPrintBarrage.Checked), + new JProperty("printFilter", GetFilterString(clbPrintFilter)), + new JProperty("pushFilter", GetFilterString(clbPushFilter)), + new JProperty("barrageFileLog", cbBarrageLog.Checked), + new JProperty("logFilter", GetFilterString(clbLogFilter)), + new JProperty("polling", new JObject( + new JProperty("enabled", cbForcePolling.Checked), + new JProperty("interval", (int)nudPollingInterval.Value))))), + new JProperty("liveCompanion", new JObject( + new JProperty("path", txtLiveCompanPath.Text.Trim()), + new JProperty("hookEnabled", cbLiveCompanHook.Checked), + new JProperty("autoPause", cbAutoPause.Checked))), + new JProperty("comPort", new JObject( + new JProperty("config", BuildComPortConfig())))))); + File.WriteAllText(ConfigPath, json.ToString(), Encoding.UTF8); + return; + } + + var config = JObject.Parse(File.ReadAllText(ConfigPath), new JsonLoadSettings { CommentHandling = CommentHandling.Ignore }); + var app = config["app"]; + app["general"]["platform"] = cmbPlatform.SelectedItem?.ToString() ?? "douyin"; + app["general"]["hideConsole"] = cbHideConsole.Checked; + app["general"]["showWindow"] = cbShowWindow.Checked; + app["network"]["proxy"]["port"] = (int)nudProxyPort.Value; + app["network"]["proxy"]["enabled"] = cbProxyEnabled.Checked; + app["network"]["proxy"]["upstreamAddress"] = txtUpstreamProxy.Text.Trim(); + app["network"]["websocket"]["listenPort"] = (int)nudWsPort.Value; + app["network"]["websocket"]["listenAny"] = cbListenAny.Checked; + app["filtering"]["processFilter"] = txtProcessFilter.Text.Trim(); + app["filtering"]["hostNameEnabled"] = cbFilterHostName.Checked; + app["filtering"]["hostNameList"] = txtHostNameList.Text.Trim(); + app["filtering"]["webRoomIds"] = txtWebRoomIds.Text.Trim(); + app["barrage"]["printEnabled"] = cbPrintBarrage.Checked; + app["barrage"]["printFilter"] = GetFilterString(clbPrintFilter); + app["barrage"]["pushFilter"] = GetFilterString(clbPushFilter); + app["barrage"]["barrageFileLog"] = cbBarrageLog.Checked; + app["barrage"]["logFilter"] = GetFilterString(clbLogFilter); + app["barrage"]["polling"]["enabled"] = cbForcePolling.Checked; + app["barrage"]["polling"]["interval"] = (int)nudPollingInterval.Value; + app["liveCompanion"]["path"] = txtLiveCompanPath.Text.Trim(); + app["liveCompanion"]["hookEnabled"] = cbLiveCompanHook.Checked; + app["liveCompanion"]["autoPause"] = cbAutoPause.Checked; + app["comPort"]["config"] = BuildComPortConfig(); + File.WriteAllText(ConfigPath, config.ToString(), Encoding.UTF8); + } + + string BuildComPortConfig() + { + var port = txtComPort.Text.Trim(); + var baud = txtComBaud.Text.Trim(); + if (string.IsNullOrEmpty(port)) return ""; + if (string.IsNullOrEmpty(baud)) baud = "9600"; + return $"{port}:{baud}"; + } + + void SaveGameSettings() + { + var json = new JObject( + new JProperty("gameExePath", txtGameExePath.Text.Trim()), + new JProperty("gameWorkDir", txtGameWorkDir.Text.Trim()), + new JProperty("autoLaunchGame", cbAutoLaunchGame.Checked), + new JProperty("configDir", txtConfigDir.Text.Trim()), + new JProperty("configFiles", new JObject( + new JProperty("effect", txtEffectFile.Text.Trim()), + new JProperty("macro", txtMacroFile.Text.Trim()), + new JProperty("gift", txtGiftFile.Text.Trim()), + new JProperty("command", txtCmdFile.Text.Trim()), + new JProperty("fkey", txtFkeyFile.Text.Trim())))); + File.WriteAllText(GameSettingsPath, json.ToString(), Encoding.UTF8); + + // 同步到 Program + Program.AutoLaunchGame = cbAutoLaunchGame.Checked; + Program.SetGamePaths(txtGameExePath.Text.Trim(), txtGameWorkDir.Text.Trim()); + Program.SetConfigPaths(txtConfigDir.Text.Trim(), + txtEffectFile.Text.Trim(), txtMacroFile.Text.Trim(), + txtGiftFile.Text.Trim(), txtCmdFile.Text.Trim(), + txtFkeyFile.Text.Trim()); + } + } +} diff --git a/BarrageGrab/Modles/BiliProtoEntity/BiliMessages.cs b/BarrageGrab/Modles/BiliProtoEntity/BiliMessages.cs new file mode 100644 index 0000000..ca182df --- /dev/null +++ b/BarrageGrab/Modles/BiliProtoEntity/BiliMessages.cs @@ -0,0 +1,192 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace BarrageGrab.Modles.BiliProtoEntity +{ + /// + /// B站直播弹幕WS协议操作码 + /// + public enum BiliOperation + { + /// 客户端心跳 + HEARTBEAT = 2, + /// 服务器心跳回复 (人气值) + HEARTBEAT_REPLY = 3, + /// 普通消息 (弹幕/礼物/进房等) + MESSAGE = 5, + /// 客户端认证请求 + AUTH = 7, + /// 服务器认证回复 + AUTH_REPLY = 8 + } + + /// + /// B站WS协议版本 (影响Payload压缩方式) + /// + public enum BiliProtocolVersion + { + /// JSON明文 + PLAIN = 0, + /// 认证用JSON + PLAIN_AUTH = 1, + /// Zlib压缩 (内部可能含多个包) + ZLIB = 2, + /// Brotli压缩 (内部可能含多个包) + BROTLI = 3 + } + + /// + /// B站WS协议帧头 (16字节, Big-Endian) + /// + public struct BiliSocketFrame + { + /// 整个包长度 (header+body) + public uint PacketLength; + /// 头长度 (固定16) + public ushort HeaderLength; + /// 协议版本 + public BiliProtocolVersion ProtocolVersion; + /// 操作码 + public BiliOperation Operation; + /// 序列号 (固定1) + public uint SequenceId; + + public const int HEADER_SIZE = 16; + + /// 从字节缓冲区解析帧头 + public static bool TryParse(byte[] buffer, int offset, int count, out BiliSocketFrame frame) + { + frame = default(BiliSocketFrame); + if (buffer == null || count < HEADER_SIZE) return false; + // Big-Endian 解析 + frame.PacketLength = ReadUInt32BE(buffer, offset); + frame.HeaderLength = ReadUInt16BE(buffer, offset + 4); + frame.ProtocolVersion = (BiliProtocolVersion)ReadUInt16BE(buffer, offset + 6); + frame.Operation = (BiliOperation)ReadUInt32BE(buffer, offset + 8); + frame.SequenceId = ReadUInt32BE(buffer, offset + 12); + return frame.HeaderLength >= HEADER_SIZE && frame.PacketLength >= HEADER_SIZE; + } + + /// 帧体数据 (从 HeaderLength 开始到 PacketLength) + public static byte[] GetPayload(byte[] buffer, int offset, BiliSocketFrame frame) + { + int bodyLen = (int)frame.PacketLength - frame.HeaderLength; + if (bodyLen <= 0) return new byte[0]; + var data = new byte[bodyLen]; + System.Array.Copy(buffer, offset + frame.HeaderLength, data, 0, bodyLen); + return data; + } + + public static uint ReadUInt32BE(byte[] buf, int off) + { + return (uint)((buf[off] << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3]); + } + + public static ushort ReadUInt16BE(byte[] buf, int off) + { + return (ushort)((buf[off] << 8) | buf[off + 1]); + } + } + + /// + /// B站弹幕消息 (cmd字段用于路由) + /// + public class BiliMessage + { + /// 消息类型 (DANMU_MSG / SEND_GIFT / INTERACT_WORD 等) + public string Cmd { get; set; } + + /// 原始数据 + public JObject Raw { get; set; } + } + + /// + /// B站弹幕消息 cmd 列表 (用于识别已知类型) + /// + public static class BiliCmd + { + public const string DANMU_MSG = "DANMU_MSG"; + public const string SEND_GIFT = "SEND_GIFT"; + public const string INTERACT_WORD = "INTERACT_WORD"; + public const string LIKE_INFO_V3_CLICK = "LIKE_INFO_V3_CLICK"; + public const string ONLINE_RANK_COUNT = "ONLINE_RANK_COUNT"; + public const string WATCHED_CHANGE = "WATCHED_CHANGE"; + public const string LIVE = "LIVE"; + public const string PREPARING = "PREPARING"; + public const string ROOM_REAL_TIME_MESSAGE_UPDATE = "ROOM_REAL_TIME_MESSAGE_UPDATE"; + public const string SUPER_CHAT_MESSAGE = "SUPER_CHAT_MESSAGE"; + } + + /// + /// B站弹幕消息(DANMU_MSG)简化结构 + /// + public class BiliDanmuMsg + { + /// 弹幕内容 (info[1]) + public string Content { get; set; } + /// 用户UID (info[2][0]) + public long Uid { get; set; } + /// 用户昵称 (info[2][1]) + public string Uname { get; set; } + /// 管理员? (info[2][2]==1) + public bool IsAdmin { get; set; } + /// 主播? (info[2][4]存在) + public bool IsAnchor { get; set; } + } + + /// + /// B站礼物消息(SEND_GIFT.data)简化结构 + /// + public class BiliGiftData + { + public long Uid { get; set; } + public string Uname { get; set; } + public string Face { get; set; } + public uint GiftId { get; set; } + public string GiftName { get; set; } + /// 礼物数量 + public uint Num { get; set; } + /// 本批次礼物数 (连击时为本次增量) + public uint ComboNum { get; set; } + /// 礼物连击总数 + public uint SuperBatchNum { get; set; } + /// 价格 (1 = 1000金瓜子) + public long Price { get; set; } + /// 金币类型 gold=付费 silver=免费 + public string CoinType { get; set; } + /// 礼物图片 + public string GifUrl { get; set; } + } + + /// + /// B站进房/关注消息(INTERACT_WORD.data)简化结构 + /// + public class BiliInteractData + { + public long Uid { get; set; } + public string Uname { get; set; } + /// 1=进入 2=关注 3=分享 + public int MsgType { get; set; } + } + + /// + /// B站点赞消息(LIKE_INFO_V3_CLICK.data)简化结构 + /// + public class BiliLikeData + { + public long Uid { get; set; } + public string Uname { get; set; } + public string LikeText { get; set; } + } + + /// + /// B站房间统计(ONLINE_RANK_COUNT.data / ROOM_REAL_TIME_MESSAGE_UPDATE.data)简化结构 + /// + public class BiliRoomStats + { + /// 在线人数 + public long Online { get; set; } + /// 看过人数 + public long Watched { get; set; } + } +} diff --git a/BarrageGrab/Modles/JsonEntity/BarrageMessages.cs b/BarrageGrab/Modles/JsonEntity/BarrageMessages.cs index 7c8b844..fb7d0ae 100644 --- a/BarrageGrab/Modles/JsonEntity/BarrageMessages.cs +++ b/BarrageGrab/Modles/JsonEntity/BarrageMessages.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using Newtonsoft.Json.Linq; using Newtonsoft.Json; @@ -6,6 +6,25 @@ using Org.BouncyCastle.Asn1.Crmf; namespace BarrageGrab.Modles.JsonEntity { + /// + /// 直播平台 + /// + public enum LivePlatform + { + /// + /// 抖音 + /// + Douyin = 0, + /// + /// 快手 + /// + Kuaishou = 1, + /// + /// 哔哩哔哩 + /// + Bilibili = 2 + } + /// /// 弹幕消息类型 /// @@ -96,6 +115,11 @@ namespace BarrageGrab.Modles.JsonEntity /// public string ProcessName { get; set; } + /// + /// 直播平台 + /// + public LivePlatform Platform { get; set; } = LivePlatform.Douyin; + /// /// 消息对象 /// @@ -106,11 +130,12 @@ namespace BarrageGrab.Modles.JsonEntity } - public BarrageMsgPack(string data, PackMsgType type, string processName) + public BarrageMsgPack(string data, PackMsgType type, string processName, LivePlatform platform = LivePlatform.Douyin) { Data = data; Type = type; ProcessName = processName; + Platform = platform; } /// diff --git a/BarrageGrab/Modles/KsProtoEntity/KsMessages.cs b/BarrageGrab/Modles/KsProtoEntity/KsMessages.cs new file mode 100644 index 0000000..4840da3 --- /dev/null +++ b/BarrageGrab/Modles/KsProtoEntity/KsMessages.cs @@ -0,0 +1,358 @@ +using System.Collections.Generic; +using ProtoBuf; + +namespace BarrageGrab.Modles.KsProtoEntity +{ + [ProtoContract] + public class KsSocketMessage + { + [ProtoMember(1)] + public KsPayloadType PayloadType { get; set; } + + [ProtoMember(2)] + public KsCompressionType CompressionType { get; set; } + + [ProtoMember(3)] + public byte[] Payload { get; set; } + } + + public enum KsCompressionType + { + UNKNOWN = 0, + NONE = 1, + GZIP = 2, + AES = 3 + } + + public enum KsPayloadType + { + UNKNOWN = 0, + CS_HEARTBEAT = 1, + CS_ERROR = 3, + CS_PING = 4, + PS_HOST_INFO = 51, + SC_HEARTBEAT_ACK = 101, + SC_ECHO = 102, + SC_ERROR = 103, + SC_PING_ACK = 104, + SC_INFO = 105, + CS_ENTER_ROOM = 200, + CS_USER_PAUSE = 201, + CS_USER_EXIT = 202, + CS_AUTHOR_PUSH_TRAFFIC_ZERO = 203, + CS_HORSE_RACING = 204, + CS_RACE_LOSE = 205, + CS_VOIP_SIGNAL = 206, + SC_ENTER_ROOM_ACK = 300, + SC_AUTHOR_PAUSE = 301, + SC_AUTHOR_RESUME = 302, + SC_AUTHOR_PUSH_TRAFFIC_ZERO = 303, + SC_AUTHOR_HEARTBEAT_MISS = 304, + SC_PIP_STARTED = 305, + SC_PIP_ENDED = 306, + SC_HORSE_RACING_ACK = 307, + SC_VOIP_SIGNAL = 308, + SC_FEED_PUSH = 310, + SC_ASSISTANT_STATUS = 311, + SC_REFRESH_WALLET = 312, + SC_LIVE_CHAT_CALL = 320, + SC_LIVE_CHAT_CALL_ACCEPTED = 321, + SC_LIVE_CHAT_CALL_REJECTED = 322, + SC_LIVE_CHAT_READY = 323, + SC_LIVE_CHAT_GUEST_END = 324, + SC_LIVE_CHAT_ENDED = 325, + SC_RENDERING_MAGIC_FACE_DISABLE = 326, + SC_RENDERING_MAGIC_FACE_ENABLE = 327, + SC_RED_PACK_FEED = 330, + SC_LIVE_WATCHING_LIST = 340, + SC_LIVE_QUIZ_QUESTION_ASKED = 350, + SC_LIVE_QUIZ_QUESTION_REVIEWED = 351, + SC_LIVE_QUIZ_SYNC = 352, + SC_LIVE_QUIZ_ENDED = 353, + SC_LIVE_QUIZ_WINNERS = 354, + SC_SUSPECTED_VIOLATION = 355, + SC_SHOP_OPENED = 360, + SC_SHOP_CLOSED = 361, + SC_GUESS_OPENED = 370, + SC_GUESS_CLOSED = 371, + SC_PK_INVITATION = 380, + SC_PK_STATISTIC = 381, + SC_RIDDLE_OPENED = 390, + SC_RIDDLE_CLOESED = 391, + SC_RIDE_CHANGED = 412, + SC_BET_CHANGED = 441, + SC_BET_CLOSED = 442, + SC_LIVE_SPECIAL_ACCOUNT_CONFIG_STATE = 645 + } + + [ProtoContract] + public class KsSCWebFeedPush + { + [ProtoMember(1)] + public string DisplayWatchingCount { get; set; } + + [ProtoMember(2)] + public string DisplayLikeCount { get; set; } + + [ProtoMember(3)] + public ulong PendingLikeCount { get; set; } + + [ProtoMember(4)] + public ulong PushInterval { get; set; } + + [ProtoMember(5)] + public List CommentFeeds { get; } = new List(); + + [ProtoMember(6)] + public string CommentCursor { get; set; } + + [ProtoMember(7)] + public List ComboCommentFeed { get; } = new List(); + + [ProtoMember(8)] + public List LikeFeeds { get; } = new List(); + + [ProtoMember(9)] + public List GiftFeeds { get; } = new List(); + + [ProtoMember(10)] + public string GiftCursor { get; set; } + + [ProtoMember(11)] + public List SystemNoticeFeeds { get; } = new List(); + + [ProtoMember(12)] + public List ShareFeeds { get; } = new List(); + } + + [ProtoContract] + public class KsWebCommentFeed + { + [ProtoMember(1)] + public string Id { get; set; } + + [ProtoMember(2)] + public KsSimpleUserInfo User { get; set; } + + [ProtoMember(3)] + public string Content { get; set; } + + [ProtoMember(4)] + public string DeviceHash { get; set; } + + [ProtoMember(5)] + public ulong SortRank { get; set; } + + [ProtoMember(6)] + public string Color { get; set; } + + [ProtoMember(7)] + public KsWebCommentFeedShowType ShowType { get; set; } + } + + public enum KsWebCommentFeedShowType + { + FEED_SHOW_UNKNOWN = 0, + FEED_SHOW_NORMAL = 1, + FEED_HIDDEN = 2 + } + + [ProtoContract] + public class KsWebComboCommentFeed + { + [ProtoMember(1)] + public string Id { get; set; } + + [ProtoMember(2)] + public string Content { get; set; } + + [ProtoMember(3)] + public uint ComboCount { get; set; } + } + + [ProtoContract] + public class KsWebLikeFeed + { + [ProtoMember(1)] + public string Id { get; set; } + + [ProtoMember(2)] + public KsSimpleUserInfo User { get; set; } + + [ProtoMember(3)] + public ulong SortRank { get; set; } + + [ProtoMember(4)] + public string DeviceHash { get; set; } + } + + [ProtoContract] + public class KsWebGiftFeed + { + [ProtoMember(1)] + public string Id { get; set; } + + [ProtoMember(2)] + public KsSimpleUserInfo User { get; set; } + + [ProtoMember(3)] + public ulong Time { get; set; } + + [ProtoMember(4)] + public uint GiftId { get; set; } + + [ProtoMember(5)] + public ulong SortRank { get; set; } + + [ProtoMember(6)] + public string MergeKey { get; set; } + + [ProtoMember(7)] + public uint BatchSize { get; set; } + + [ProtoMember(8)] + public uint ComboCount { get; set; } + + [ProtoMember(9)] + public uint Rank { get; set; } + + [ProtoMember(10)] + public ulong ExpireDuration { get; set; } + + [ProtoMember(11)] + public ulong ClientTimestamp { get; set; } + + [ProtoMember(12)] + public ulong SlotDisplayDuration { get; set; } + + [ProtoMember(13)] + public uint StarLevel { get; set; } + + [ProtoMember(14)] + public KsGiftStyleType StyleType { get; set; } + + [ProtoMember(15)] + public KsWebLiveAssistantType LiveAssistantType { get; set; } + + [ProtoMember(16)] + public string DeviceHash { get; set; } + + [ProtoMember(17)] + public bool DanmakuDisplay { get; set; } + } + + public enum KsGiftStyleType + { + UNKNOWN_STYLE = 0, + BATCH_STAR_0 = 1, + BATCH_STAR_1 = 2, + BATCH_STAR_2 = 3, + BATCH_STAR_3 = 4, + BATCH_STAR_4 = 5, + BATCH_STAR_5 = 6, + BATCH_STAR_6 = 7 + } + + public enum KsWebLiveAssistantType + { + UNKNOWN_ASSISTANT_TYPE = 0, + SUPER = 1, + JUNIOR = 2 + } + + [ProtoContract] + public class KsWebSystemNoticeFeed + { + [ProtoMember(1)] + public string Id { get; set; } + + [ProtoMember(2)] + public KsSimpleUserInfo User { get; set; } + + [ProtoMember(3)] + public ulong Time { get; set; } + + [ProtoMember(4)] + public string Content { get; set; } + + [ProtoMember(5)] + public ulong DisplayDuration { get; set; } + + [ProtoMember(6)] + public ulong SortRank { get; set; } + + [ProtoMember(7)] + public KsDisplayType DisplayType { get; set; } + } + + public enum KsDisplayType + { + UNKNOWN_DISPLAY_TYPE = 0, + COMMENT = 1, + ALERT = 2, + TOAST = 3 + } + + [ProtoContract] + public class KsWebShareFeed + { + [ProtoMember(1)] + public string Id { get; set; } + + [ProtoMember(2)] + public KsSimpleUserInfo User { get; set; } + + [ProtoMember(3)] + public ulong Time { get; set; } + + [ProtoMember(4)] + public uint ThirdPartyPlatform { get; set; } + + [ProtoMember(5)] + public ulong SortRank { get; set; } + + [ProtoMember(6)] + public KsWebLiveAssistantType LiveAssistantType { get; set; } + + [ProtoMember(7)] + public string DeviceHash { get; set; } + } + + [ProtoContract] + public class KsSimpleUserInfo + { + [ProtoMember(1)] + public string PrincipalId { get; set; } + + [ProtoMember(2)] + public string UserName { get; set; } + + [ProtoMember(3)] + public string HeadUrl { get; set; } + } + + [ProtoContract] + public class KsCSWebEnterRoom + { + [ProtoMember(1)] + public string Token { get; set; } + + [ProtoMember(2)] + public string LiveStreamId { get; set; } + + [ProtoMember(3)] + public uint ReconnectCount { get; set; } + + [ProtoMember(4)] + public uint LastErrorCode { get; set; } + + [ProtoMember(5)] + public string ExpTag { get; set; } + + [ProtoMember(6)] + public string Attach { get; set; } + + [ProtoMember(7)] + public string PageId { get; set; } + } +} diff --git a/BarrageGrab/Program.cs b/BarrageGrab/Program.cs index a9196cc..7f85130 100644 --- a/BarrageGrab/Program.cs +++ b/BarrageGrab/Program.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; +using System; using System.IO; using System.Linq; -using System.Net; -using System.Net.Sockets; using System.Diagnostics; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Windows.Forms; +using BarrageGrab.Modles.JsonEntity; +using Newtonsoft.Json.Linq; using Jint.Runtime; namespace BarrageGrab @@ -21,16 +19,225 @@ namespace BarrageGrab static WinApi.ControlCtrlDelegate controlCtr = ControlCtrlHandle; static Mutex mutex = new Mutex(false, "DyBarrageGrab"); + // 插件所在目录(用于解析相对路径) + public static string BaseDir => AppDomain.CurrentDomain.BaseDirectory; + + // === 启动项 (支持相对路径,相对于插件所在目录) === + public static bool AutoLaunchGame = true; + public static string GameExePath = @"..\Fantasy.Map.Simulator\FantasyMapSimulator.exe"; + public static string GameWorkDir = @"..\Fantasy.Map.Simulator"; + + // === 配置表路径 (支持相对路径) === + public static string ConfigDir = @"..\Fantasy.Map.Simulator\BepInEx\config"; + public static string EffectFile = "effect_config.txt"; + public static string MacroFile = "macro_config.txt"; + public static string GiftFile = "gift_config.txt"; + public static string CommandFile = "command_config.txt"; + public static string FkeyFile = "fkey_config.txt"; + + /// 将相对路径解析为绝对路径(基于插件所在目录)。已是绝对路径则原样返回。 + public static string ResolveFullPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return path; + return Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(BaseDir, path)); + } + + // 解析后的绝对路径(只读) + public static string GameExeFullPath => ResolveFullPath(GameExePath); + public static string GameWorkFullPath => ResolveFullPath(GameWorkDir); + public static string ConfigDirFullPath => ResolveFullPath(ConfigDir); + + // 配置表完整路径(只读,基于 ConfigDirFullPath) + public static string EffectPath => Path.Combine(ConfigDirFullPath, EffectFile); + public static string MacroPath => Path.Combine(ConfigDirFullPath, MacroFile); + public static string GiftPath => Path.Combine(ConfigDirFullPath, GiftFile); + public static string CommandPath => Path.Combine(ConfigDirFullPath, CommandFile); + public static string FkeyPath => Path.Combine(ConfigDirFullPath, FkeyFile); + + public static void SetGamePaths(string exePath, string workDir) + { + if (!string.IsNullOrWhiteSpace(exePath)) GameExePath = exePath; + if (!string.IsNullOrWhiteSpace(workDir)) GameWorkDir = workDir; + } + + public static void SetConfigPaths(string configDir, + string effectFile, string macroFile, string giftFile, string cmdFile, string fkeyFile) + { + if (!string.IsNullOrWhiteSpace(configDir)) ConfigDir = configDir; + if (!string.IsNullOrWhiteSpace(effectFile)) EffectFile = effectFile; + if (!string.IsNullOrWhiteSpace(macroFile)) MacroFile = macroFile; + if (!string.IsNullOrWhiteSpace(giftFile)) GiftFile = giftFile; + if (!string.IsNullOrWhiteSpace(cmdFile)) CommandFile = cmdFile; + if (!string.IsNullOrWhiteSpace(fkeyFile)) FkeyFile = fkeyFile; + } + + /// 从 GameExeFullPath 提取进程名(不带扩展名) + static string GameProcessName + { + get + { + try { return Path.GetFileNameWithoutExtension(GameExeFullPath); } + catch { return "FantasyMapSimulator"; } + } + } + + /// 关闭所有同名游戏进程,返回是否已无残留 + static bool CloseGameProcesses() + { + var procName = GameProcessName; + var procs = Process.GetProcessesByName(procName); + if (procs.Length == 0) + { + Logger.PrintColor("未发现运行中的游戏进程 (" + procName + ")", ConsoleColor.DarkGray); + return true; + } + + Logger.PrintColor("发现 " + procs.Length + " 个游戏进程 (" + procName + "),正在关闭...", ConsoleColor.Yellow); + int killed = 0, failed = 0; + foreach (var p in procs) + { + try + { + if (!p.HasExited) + { + try { p.CloseMainWindow(); } catch { } + if (!p.WaitForExit(2000)) { p.Kill(); p.WaitForExit(3000); } + } + killed++; + } + catch { failed++; } + finally { try { p.Close(); } catch { } } + } + + Thread.Sleep(500); + var remain = Process.GetProcessesByName(procName); + if (remain.Length > 0) + { + Logger.PrintColor("仍有 " + remain.Length + " 个进程未退出,强制结束...", ConsoleColor.Yellow); + foreach (var r in remain) + { + try { if (!r.HasExited) r.Kill(); } catch { } + finally { try { r.Close(); } catch { } } + } + Thread.Sleep(1000); + } + + bool allClosed = Process.GetProcessesByName(procName).Length == 0; + Logger.PrintColor("关闭完成 (成功" + killed + ", 失败" + failed + ")", allClosed ? ConsoleColor.Green : ConsoleColor.Yellow); + return allClosed; + } + + public static void LaunchGame() + { + try + { + if (!File.Exists(GameExeFullPath)) + { + MessageBox.Show("找不到游戏文件:\n" + GameExeFullPath + "\n(配置路径: " + GameExePath + ")", "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + var existing = Process.GetProcessesByName(GameProcessName); + if (existing.Length > 0) + { + Logger.PrintColor("游戏已在运行中,跳过启动", ConsoleColor.Yellow); + foreach (var p in existing) try { p.Close(); } catch { } + return; + } + StartGameProcess(); + } + catch (Exception ex) + { + Logger.LogError(ex, "启动游戏失败: " + ex.Message); + } + } + + public static void RestartGame() + { + try + { + if (!File.Exists(GameExeFullPath)) + { + MessageBox.Show("找不到游戏文件:\n" + GameExeFullPath + "\n(配置路径: " + GameExePath + ")", "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + Logger.PrintColor("── 重启游戏:关闭进程 ──", ConsoleColor.Cyan); + bool closed = CloseGameProcesses(); + if (!closed) + { + Logger.PrintColor("警告: 部分游戏进程未能完全关闭,仍尝试重新启动", ConsoleColor.Yellow); + } + + Thread.Sleep(1500); + + Logger.PrintColor("── 重启游戏:重新启动 ──", ConsoleColor.Cyan); + StartGameProcess(); + } + catch (Exception ex) + { + Logger.LogError(ex, "重启游戏失败: " + ex.Message); + } + } + private static void StartGameProcess() + { + var psi = new ProcessStartInfo(GameExeFullPath) + { + WorkingDirectory = GameWorkFullPath, + UseShellExecute = true + }; + Process.Start(psi); + Logger.PrintColor("游戏已启动", ConsoleColor.Green); + } + + /// 加载 game_settings.json (启动项 + 配置表路径) + private static void LoadGameSettings() + { + try + { + string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "game_settings.json"); + if (!File.Exists(path)) return; + var json = JObject.Parse(File.ReadAllText(path)); + + // 启动项 + string exe = json["gameExePath"]?.Value(); + string dir = json["gameWorkDir"]?.Value(); + bool auto = json["autoLaunchGame"]?.Value() ?? true; + if (!string.IsNullOrWhiteSpace(exe)) GameExePath = exe; + if (!string.IsNullOrWhiteSpace(dir)) GameWorkDir = dir; + AutoLaunchGame = auto; + + // 配置表路径 + string configDir = json["configDir"]?.Value(); + if (!string.IsNullOrWhiteSpace(configDir)) ConfigDir = configDir; + var cfg = json["configFiles"]; + if (cfg != null) + { + string eff = cfg["effect"]?.Value(); + string mac = cfg["macro"]?.Value(); + string gift = cfg["gift"]?.Value(); + string cmd = cfg["command"]?.Value(); + string fkey = cfg["fkey"]?.Value(); + if (!string.IsNullOrWhiteSpace(eff)) EffectFile = eff; + if (!string.IsNullOrWhiteSpace(mac)) MacroFile = mac; + if (!string.IsNullOrWhiteSpace(gift)) GiftFile = gift; + if (!string.IsNullOrWhiteSpace(cmd)) CommandFile = cmd; + if (!string.IsNullOrWhiteSpace(fkey)) FkeyFile = fkey; + } + + Logger.PrintColor($"已加载游戏设置: 自动启动={AutoLaunchGame}, 配置目录={ConfigDir}", ConsoleColor.DarkGray); + } + catch (Exception ex) + { + Logger.PrintColor($"加载游戏设置失败: {ex.Message}", ConsoleColor.Yellow); + } + } + private const string WatchdogArg = "--watchdog"; private const string WatchdogPidArg = "--pid"; static void Main(string[] args) { - if (IsWatchdogMode(args)) - { - RunWatchdog(args); - return; - } + if (IsWatchdogMode(args)) { RunWatchdog(args); return; } if (!mutex.WaitOne(TimeSpan.Zero, true)) { @@ -39,14 +246,11 @@ namespace BarrageGrab return; } - SetTitle("抖音弹幕监听推送"); - + SetTitle("弹幕监听推送"); StartWatchdog(); + LoadGameSettings(); - try - { - Init(); - } + try { Init(); } catch (Exception ex) { Logger.LogError(ex, $"程序初始化错误,{ex.Message}"); @@ -54,18 +258,11 @@ namespace BarrageGrab exited = true; } - while (!exited) - { - Thread.Sleep(500); - } - - if (!AppRuntime.WsServer.IsDisposed) - { - AppRuntime.WsServer.Dispose(); - } + while (!exited) Thread.Sleep(500); + if (!AppRuntime.WsServer.IsDisposed) AppRuntime.WsServer.Dispose(); Logger.PrintColor("服务器已关闭..."); - WinApi.SetConsoleCtrlHandler(controlCtr, false);//反注册捕获控制台关闭 + WinApi.SetConsoleCtrlHandler(controlCtr, false); } private static void StartWatchdog() @@ -74,21 +271,12 @@ namespace BarrageGrab { var exePath = Application.ExecutablePath; if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath)) return; - var pid = Process.GetCurrentProcess().Id; var args = string.Format("{0} {1} {2}", WatchdogArg, WatchdogPidArg, pid); - var psi = new ProcessStartInfo(exePath, args) - { - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden - }; + var psi = new ProcessStartInfo(exePath, args) { UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden }; Process.Start(psi); } - catch - { - // best-effort only - } + catch { } } private static bool IsWatchdogMode(string[] args) @@ -116,106 +304,69 @@ namespace BarrageGrab { var pid = TryGetWatchdogPid(args); if (pid == null || pid <= 0) return; - Process target; - try - { - target = Process.GetProcessById(pid.Value); - } - catch - { - SafeCloseSystemProxy(); - return; - } - - for (; ; ) - { - if (target.HasExited) break; - Thread.Sleep(1000); - } - + try { target = Process.GetProcessById(pid.Value); } + catch { SafeCloseSystemProxy(); return; } + for (; ; ) { if (target.HasExited) break; Thread.Sleep(1000); } SafeCloseSystemProxy(); } - catch - { - try { SafeCloseSystemProxy(); } catch { } - } + catch { try { SafeCloseSystemProxy(); } catch { } } } private static void SafeCloseSystemProxy() { - try - { - if (!AppSetting.Current.UsedProxy) return; - WinApi.CloseSystemProxy(); - } - catch - { - } + try { if (!AppSetting.Current.UsedProxy) return; WinApi.CloseSystemProxy(); } + catch { } } private static void Init() { AppRuntime.Init(); LiveCompanHelper.SwitchSetup(); - WinApi.SetConsoleCtrlHandler(controlCtr, true);//捕获控制台关闭 - WinApi.DisableQuickEditMode();//禁用控制台快速编辑模式 - AppRuntime.DisplayConsole(!AppSetting.Current.HideConsole);//控制控制台可见 - AppRuntime.WsServer.Grab.Proxy.SetUpstreamProxy(AppSetting.Current.UpstreamProxy);//设置上游代理 - AppRuntime.WsServer.OnClose += (s, e) => - { - exited = true; - }; + WinApi.SetConsoleCtrlHandler(controlCtr, true); + WinApi.DisableQuickEditMode(); + AppRuntime.DisplayConsole(!AppSetting.Current.HideConsole); + AppRuntime.WsServer.Grab.Proxy.SetUpstreamProxy(AppSetting.Current.UpstreamProxy); + AppRuntime.WsServer.OnClose += (s, e) => { exited = true; }; - //串口写入服务 if (!AppSetting.Current.ComPort.IsNullOrWhiteSpace()) - { AppRuntime.ComPortServer.OpenStart(); - } - //显示窗体 if (AppSetting.Current.ShowWindow) { var uiThread = new Thread(new ThreadStart(() => { mainForm = new FormView(); - //开启消息循环 System.Windows.Forms.Application.Run(mainForm); formExited = true; - AppRuntime.WsServer.Dispose();//Close后自动释放资源 + AppRuntime.WsServer.Dispose(); })); uiThread.SetApartmentState(ApartmentState.STA); uiThread.IsBackground = true; uiThread.Start(); } - AppRuntime.WsServer.StartListen();//启动WS以及代理服务 - Logger.PrintColor($"{AppRuntime.WsServer.ServerLocation} 弹幕服务已启动,其他端可通过此地址获取到弹幕流信息", ConsoleColor.Green); + AppRuntime.WsServer.StartListen(); + Logger.PrintColor($"{AppRuntime.WsServer.ServerLocation} 弹幕服务已启动", ConsoleColor.Green); + + if (AutoLaunchGame) LaunchGame(); Version version = System.Reflection.Assembly.GetAssembly(typeof(Program)).GetName().Version; - SetTitle($"抖音弹幕监听推送 v{version} [{AppRuntime.WsServer.ServerLocation}]"); + SetTitle($"弹幕监听推送 v{version} [{AppRuntime.WsServer.ServerLocation}]"); } - //检测设置控制台标题 private static void SetTitle(string title) { - if (WinApi.GetConsoleWindow() != IntPtr.Zero) - { - Console.Title = title; - } + if (WinApi.GetConsoleWindow() != IntPtr.Zero) Console.Title = title; } - //监听控制台消息事件 private static bool ControlCtrlHandle(int CtrlType) { switch (CtrlType) { - case 0: - //Logger.PrintColor("0工具被强制关闭"); //Ctrl+C关闭 - //server.Close(); - break; + case 0: break; case 2: - Logger.PrintColor("2工具被强制关闭");//按控制台关闭按钮关闭 + Logger.PrintColor("2工具被强制关闭"); AppRuntime.WsServer.Dispose(); break; } diff --git a/BarrageGrab/Properties/app.manifest b/BarrageGrab/Properties/app.manifest index 5a95963..2ae9932 100644 --- a/BarrageGrab/Properties/app.manifest +++ b/BarrageGrab/Properties/app.manifest @@ -1,73 +1,11 @@ - + - - + - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/BarrageGrab/Proxy/ISystemProxy.cs b/BarrageGrab/Proxy/ISystemProxy.cs index aa8ab64..56192e9 100644 --- a/BarrageGrab/Proxy/ISystemProxy.cs +++ b/BarrageGrab/Proxy/ISystemProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Reflection; using BarrageGrab.Proxy.ProxyEventArgs; @@ -9,6 +9,11 @@ namespace BarrageGrab.Proxy { event EventHandler OnWebSocketData; + /// + /// 客户端发送的WebSocket数据事件 (用于解析认证包等) + /// + event EventHandler OnWebSocketSentData; + event EventHandler OnFetchResponse; event EventHandler OnProxyStatus; diff --git a/BarrageGrab/Proxy/ProxyEventArgs/WsMessageEventArgs.cs b/BarrageGrab/Proxy/ProxyEventArgs/WsMessageEventArgs.cs index a41f195..ef47f9c 100644 --- a/BarrageGrab/Proxy/ProxyEventArgs/WsMessageEventArgs.cs +++ b/BarrageGrab/Proxy/ProxyEventArgs/WsMessageEventArgs.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using BarrageGrab.Modles.JsonEntity; namespace BarrageGrab.Proxy.ProxyEventArgs { @@ -32,5 +33,10 @@ namespace BarrageGrab.Proxy.ProxyEventArgs /// 是否需要解压缩 /// public bool NeedDecompress { get; set; } = true; + + /// + /// 直播平台 + /// + public LivePlatform Platform { get; set; } = LivePlatform.Douyin; } } diff --git a/BarrageGrab/Proxy/SystemProxy.cs b/BarrageGrab/Proxy/SystemProxy.cs index ce3bd77..9e9f09a 100644 --- a/BarrageGrab/Proxy/SystemProxy.cs +++ b/BarrageGrab/Proxy/SystemProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -19,6 +19,11 @@ namespace BarrageGrab.Proxy /// public event EventHandler OnWebSocketData; + /// + /// 客户端发送的WebSocket数据事件 (用于解析认证包等) + /// + public event EventHandler OnWebSocketSentData; + /// /// 接收到http响应事件 /// @@ -78,6 +83,15 @@ namespace BarrageGrab.Proxy OnWebSocketData?.Invoke(this, args); } + /// + /// 触发客户端发送的websocket数据事件 + /// + /// + protected void FireWsSentEvent(WsMessageEventArgs args) + { + OnWebSocketSentData?.Invoke(this, args); + } + /// /// 触发弹幕http弹幕事件 /// diff --git a/BarrageGrab/Proxy/TitaniumProxy.cs b/BarrageGrab/Proxy/TitaniumProxy.cs index 5a7133d..ec61725 100644 --- a/BarrageGrab/Proxy/TitaniumProxy.cs +++ b/BarrageGrab/Proxy/TitaniumProxy.cs @@ -43,7 +43,13 @@ namespace BarrageGrab.Proxy const string BARRAGE_POOL_PATH = "/webcast/im/fetch"; const string LIVE_SCRIPT_PATH = "/obj/static/webcast/douyin_live"; const string WEBCAST_AMEMV_HOST = "webcast.amemv.com"; + // B站直播页面域名 + const string BILI_LIVE_HOST = "live.bilibili.com"; private readonly Regex webcastBarrageReg = new Regex(@"webcast\d+-ws-web-\w+\.(douyin|amemv)\.com"); + // 2026-05 快手WS域名变更为 livejs-ws.kuaishou.cn + private readonly Regex kuaishouWsReg = new Regex(@"livejs-ws\.kuaishou\.cn|(?:livemate|pclivemate)\.pull\.yximgs\.com", RegexOptions.IgnoreCase); + // B站直播弹幕WS域名 (broadcast-msg.chat.bilibili.com 主域名, *-live-comet.chat.bilibili.com 新版comet服务器, live-trace.com 备用, ws.live.bilibili.com 历史域名) + private readonly Regex biliWsReg = new Regex(@"chat\.bilibili\.com|live-trace\.com|ws\.live\.bilibili\.com", RegexOptions.IgnoreCase); public override string HttpUpstreamProxy { get { return proxyServer?.UpStreamHttpProxy?.ToString() ?? ""; } } @@ -196,7 +202,7 @@ namespace BarrageGrab.Proxy private bool CheckBrowser(string processName) { - return AppSetting.Current.ProcessFilter.Contains(processName) && processName != "直播伴侣" && processName != "douyin"; + return AppSetting.Current.ProcessFilter.Contains(processName) && processName != "直播伴侣" && processName != "douyin" && processName != "kwailive" && processName != "bilibili"; } private async Task ProxyServer_BeforeResponse(object sender, SessionEventArgs e) @@ -221,6 +227,37 @@ namespace BarrageGrab.Proxy //处理脚本拦截修改 await HookScriptAsync(e); + + //快手礼物映射嗅探 + await HookKsGiftSniffer(e); + } + + // 快手礼物映射嗅探 - 拦截包含礼物数据的JSON响应 + private async Task HookKsGiftSniffer(SessionEventArgs e) + { + try + { + string hostname = e.HttpClient.Request.RequestUri.Host; + if (hostname.IndexOf("kuaishou", StringComparison.OrdinalIgnoreCase) < 0 && + hostname.IndexOf("yximgs", StringComparison.OrdinalIgnoreCase) < 0 && + hostname.IndexOf("kskwai", StringComparison.OrdinalIgnoreCase) < 0) + return; + + var contentType = e.HttpClient.Response.ContentType ?? ""; + + // 仅检查 JSON 响应 + if (!contentType.Contains("json") && !contentType.Contains("javascript")) + return; + + var body = await e.GetResponseBodyAsString(); + if (string.IsNullOrWhiteSpace(body) || body.Length < 20) return; + + if (KsGiftHelper.TryParseGiftData(body)) + { + KsGiftHelper.SaveMapping(); + } + } + catch { /* 嗅探失败不影响主流程 */ } } // Hook 直播伴侣开播信息并更新 @@ -341,6 +378,28 @@ namespace BarrageGrab.Proxy Logger.LogInfo($"订阅到新的弹幕流地址,roomid:{roomid}"); } + //快手 ws 方式 + if ( + e.HttpClient.ConnectRequest?.TunnelType == TunnelType.Websocket && + kuaishouWsReg.IsMatch(uri) + ) + { + e.DataReceived += WebSocket_DataReceived; + e.DataSent += WebSocket_DataSent; + Logger.LogInfo($"订阅到快手弹幕流地址,url:{uri}"); + } + + //B站 ws 方式 + if ( + e.HttpClient.ConnectRequest?.TunnelType == TunnelType.Websocket && + biliWsReg.IsMatch(uri) + ) + { + e.DataReceived += WebSocket_DataReceived; + e.DataSent += WebSocket_DataSent; + Logger.LogInfo($"订阅到B站弹幕流地址,url:{uri}"); + } + //轮询方式(当抖音ws连接断开后,客户端也会降级使用轮询模式获取弹幕) if (uri.Contains(BARRAGE_POOL_PATH) && contentType.Contains("application/protobuffer")) { @@ -400,6 +459,9 @@ namespace BarrageGrab.Proxy var processName = base.GetProcessName(processid); var liveRoomMactch = Regex.Match(uri.Trim(), @".*:\/\/live.douyin\.com\/([0-9a-zA-Z_]+)"); var liveHomeMatch = Regex.Match(uri.Trim(), @".*:\/\/live.douyin\.com\/?$"); + var ksLiveRoomMatch = Regex.Match(uri.Trim(), @".*:\/\/live\.kuaishou\.com\/u\/([^/?]+)"); + // B站直播间 URL: https://live.bilibili.com/ (roomId 纯数字) + var biliLiveRoomMatch = Regex.Match(uri.Trim(), @".*:\/\/live\.bilibili\.com\/(\d+)"); var contentType = e.HttpClient.Response.ContentType ?? ""; //检测是否为dom页,用于脚本注入 @@ -457,9 +519,123 @@ namespace BarrageGrab.Proxy } e.SetResponseBodyString(html); - } + } + else if (ksLiveRoomMatch.Success) + { + string userId = ksLiveRoomMatch.Groups[1].Value; + var html = await e.GetResponseBodyAsString(); + + var ksInfo = KsApiHelper.ParseRoomInfoFromHtml(html, userId); + if (ksInfo != null) + { + ksInfo.LiveUrl = url; + var roominfo = new RoomInfo() + { + WebRoomId = userId, + RoomId = ksInfo.LiveStreamId ?? userId, + Title = ksInfo.Nickname + "\u7684\u76f4\u64ad\u95f4", + IsLive = ksInfo.IsLive, + UserCount = ksInfo.WatchingCount, + LikeCount = ksInfo.LikeCount, + TotalUserCount = ksInfo.WatchingCountStr ?? "", + Cover = ksInfo.CoverUrl ?? "", + LiveUrl = url, + Owner = new RoomInfo.RoomAnchor() + { + Nickname = ksInfo.Nickname, + UserId = userId, + HeadUrl = ksInfo.HeadUrl ?? "" + } + }; + AppRuntime.RoomCaches.AddRoomInfoCache(roominfo); + Logger.LogInfo("\u5feb\u624b\u76f4\u64ad\u9875" + userId + " [" + ksInfo.Nickname + "]\u7684\u76f4\u64ad\u95f4\uff0c\u623f\u95f4\u4fe1\u606f\u5df2\u91c7\u96c6\u5230\u7f13\u5b58"); + Logger.LogInfo($"KS房间 LiveStreamId={ksInfo.LiveStreamId} Watching={ksInfo.WatchingCountStr} Liked={ksInfo.LikeCountStr} Avatar={(!string.IsNullOrEmpty(ksInfo.HeadUrl))}"); + } + + e.SetResponseBodyString(html); + + // 从HTML中嗅探礼物映射(登录后__INITIAL_STATE__含allGifts等) + if (html.Contains("allGifts") || html.Contains("giftList") || html.Contains("giftPanel")) + { + KsGiftHelper.TryParseGiftData(html); + } + } + else if (biliLiveRoomMatch.Success) + { + // B站直播间页面: B站改版后页面不再注入 __INITIAL_STATE__, 改用官方API获取房间信息 + string webRoomId = biliLiveRoomMatch.Groups[1].Value; + var html = await e.GetResponseBodyAsString(); + + // 先尝试从HTML快速解析 (兼容旧版页面) + var biliInfo = BiliApiHelper.ParseRoomInfoFromHtml(html, webRoomId); + + if (biliInfo != null && !string.IsNullOrWhiteSpace(biliInfo.RoomId) && !string.IsNullOrWhiteSpace(biliInfo.Uid)) + { + CacheBiliRoomInfo(biliInfo, webRoomId, url); + } + else if (!rinfoRequestings.Contains(webRoomId)) + { + // HTML解析失败, 异步调用B站官方API获取房间信息 + lock (rinfoRequestings) rinfoRequestings.Add(webRoomId); + + var cookie = e.HttpClient.Request.Headers.GetFirstHeader("Cookie")?.Value; + BiliApiHelper.GetRoomInfoByApi(webRoomId, cookie).ContinueWith(t => + { + try + { + var info = t.Result; + if (info != null && !string.IsNullOrWhiteSpace(info.RoomId)) + { + CacheBiliRoomInfo(info, webRoomId, url); + } + else + { + Logger.LogWarn($"B站API获取房间 {webRoomId} 信息失败"); + } + } + catch (Exception ex) + { + Logger.LogError(ex, $"B站API获取房间 {webRoomId} 信息异常: {ex.Message}"); + } + finally + { + lock (rinfoRequestings) rinfoRequestings.Remove(webRoomId); + } + }); + } + + e.SetResponseBodyString(html); + } } - + + /// 缓存B站房间信息 + private void CacheBiliRoomInfo(BiliRoomInfo biliInfo, string webRoomId, string url) + { + biliInfo.LiveUrl = url; + var roominfo = new RoomInfo() + { + WebRoomId = webRoomId, + RoomId = biliInfo.RoomId, + Title = string.IsNullOrWhiteSpace(biliInfo.Title) ? $"{biliInfo.Nickname}的直播间" : biliInfo.Title, + IsLive = biliInfo.IsLive, + UserCount = biliInfo.Online, + TotalUserCount = biliInfo.Online.ToString(), + Cover = biliInfo.CoverUrl ?? "", + LiveUrl = url, + Owner = new RoomInfo.RoomAnchor() + { + UserId = biliInfo.Uid ?? "", + Nickname = biliInfo.Nickname ?? "", + HeadUrl = biliInfo.HeadUrl ?? "" + } + }; + AppRuntime.RoomCaches.AddRoomInfoCache(roominfo); + Logger.LogInfo($"B站直播页 {webRoomId} [{biliInfo.Nickname}]的直播间,房间信息已采集到缓存 (RoomId={biliInfo.RoomId})"); + + // 同时把 WebRoomId 关联到 RoomId + AppRuntime.RoomCaches.SetRoomCache(biliInfo.RoomId, webRoomId); + } + //生成注入上下文 private string BuildContext(IDictionary constVals) { @@ -489,7 +665,7 @@ namespace BarrageGrab.Proxy //https://lf-webcast-platform.bytetos.com/obj/webcast-platform-cdn/webcast/douyin_live/chunks/island_a74ce.b55095a0.js //https://lf-webcast-platform.bytetos.com/obj/webcast-platform-cdn/webcast/douyin_live/chunks/PausePop.ec38fcf8.js 2026/04/09 //判断响应内容是否为js application/javascript - if (processName != "直播伴侣" && processName != "douyin" + if (processName != "直播伴侣" && processName != "douyin" && processName != "kwailive" && fileName.ToLower().StartsWith("pausepop") ) { @@ -543,7 +719,8 @@ namespace BarrageGrab.Proxy string url = e.HttpClient.Request.RequestUri.ToString(); string hostname = e.HttpClient.Request.RequestUri.Host; - e.DecryptSsl = CheckHost(hostname); + bool decrypt = CheckHost(hostname); + e.DecryptSsl = decrypt; //ws 方式 @@ -566,7 +743,12 @@ namespace BarrageGrab.Proxy LIVE_HOST, WEBCAST_AMEMV_HOST , //直播伴侣开播请求地址 "*-webcast-platform.bytetos.com", //新的脚本地址 - "*webcast*" //所有带webcast的域名 + "*webcast*", //所有带webcast的域名 + "*kuaishou.com", //快手直播页面 + "*kuaishou.cn", //快手WS弹幕流地址 (livejs-ws.kuaishou.cn) + "*.yximgs.com", //快手CDN (视频流/封面等) + "*bilibili.com", //B站直播页面及WS弹幕流 + "live-trace.com" //B站WS备用域名 }; if (decryptSsls.Contains(hostname)) @@ -617,6 +799,19 @@ namespace BarrageGrab.Proxy var str = Encoding.UTF8.GetString(payload); + // B站: 客户端发送的数据(认证包/心跳),触发事件供解析器提取roomid + if (biliWsReg.IsMatch(hostname)) + { + base.FireWsSentEvent(new WsMessageEventArgs() + { + ProcessID = processid, + HostName = hostname, + Payload = payload, + Platform = LivePlatform.Bilibili, + ProcessName = base.GetProcessName(processid) + }); + } + //base.FireWsEvent(new WsMessageEventArgs() //{ // ProcessID = processid, @@ -629,6 +824,7 @@ namespace BarrageGrab.Proxy } //WebSocket 流读取 + private static int wsDecodeErrCount = 0; private async void WebSocket_DataReceived(object sender, DataEventArgs e) { var args = (SessionEventArgs)sender; @@ -663,11 +859,15 @@ namespace BarrageGrab.Proxy payload = frame.Data.ToArray(); } + var platform = biliWsReg.IsMatch(hostname) ? LivePlatform.Bilibili + : kuaishouWsReg.IsMatch(hostname) ? LivePlatform.Kuaishou + : LivePlatform.Douyin; base.FireWsEvent(new WsMessageEventArgs() { ProcessID = processid, HostName = hostname, Payload = payload, + Platform = platform, ProcessName = base.GetProcessName(processid) }); } @@ -675,7 +875,11 @@ namespace BarrageGrab.Proxy } catch (Exception ex) { - Logger.PrintColor("解析某个WebSocket包出错:" + ex.Message); + if (wsDecodeErrCount < 3) + { + Logger.PrintColor("解析某个WebSocket包出错:" + ex.Message); + wsDecodeErrCount++; + } } if (messageData.Count > 0) diff --git a/BarrageGrab/Server/BiliBarrageGrab.cs b/BarrageGrab/Server/BiliBarrageGrab.cs new file mode 100644 index 0000000..e3384e4 --- /dev/null +++ b/BarrageGrab/Server/BiliBarrageGrab.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text.RegularExpressions; +using BarrageGrab.Modles.BiliProtoEntity; +using BarrageGrab.Proxy.ProxyEventArgs; +using BrotliSharpLib; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BarrageGrab +{ + /// + /// 哔哩哔哩弹幕抓取器 + /// 协议格式: 自定义16字节Big-Endian帧头 + JSON body (body可能为zlib/brotli压缩) + /// + public class BiliBarrageGrab + { + // B站直播弹幕WS域名 + // broadcast-msg.chat.bilibili.com (主域名) + // *-live-comet.chat.bilibili.com (新版comet服务器, 如 zj-cn-live-comet.chat.bilibili.com) + // live-trace.com (备用) + // ws.live.bilibili.com (历史域名) + private readonly Regex biliWsReg = new Regex(@"chat\.bilibili\.com|live-trace\.com|ws\.live\.bilibili\.com", RegexOptions.IgnoreCase); + private const string BILI_LIVE_HOST = "live.bilibili.com"; + + /// 弹幕消息 + public event EventHandler> OnChatMessage; + /// 点赞消息 + public event EventHandler> OnLikeMessage; + /// 礼物消息 + public event EventHandler> OnGiftMessage; + /// 进入直播间/关注消息 + public event EventHandler> OnInteractMessage; + /// 房间统计消息 + public event EventHandler> OnRoomStatsMessage; + /// 分享消息 (INTERACT_WORD中msg_type=3) + public event EventHandler> OnShareMessage; + + public BiliBarrageGrab() + { + } + + /// + /// 当前认证的房间ID (从AUTH包解析,供消息处理器读取) + /// + public string CurrentRoomId { get; private set; } = ""; + + public bool IsBilibiliHost(string hostname) + { + if (string.IsNullOrWhiteSpace(hostname)) return false; + hostname = hostname.Trim().ToLower(); + return biliWsReg.IsMatch(hostname) || hostname == BILI_LIVE_HOST; + } + + public bool IsBilibiliWebSocket(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) return false; + return biliWsReg.IsMatch(uri); + } + + /// + /// 处理代理收到的B站WS数据 + /// + public void HandleWebSocketData(WsMessageEventArgs e) + { + var buff = e.Payload; + if (buff == null || buff.Length == 0) return; + + try + { + // 一个WS帧可能包含多个B站数据包,需要循环解析 + ProcessBuffer(buff, 0, buff.Length, e.ProcessName); + } + catch (Exception ex) + { + if (biliErrCount < 3) + { + Logger.LogError(ex, $"处理B站弹幕数据包时出错:{ex.Message}"); + biliErrCount++; + } + if (biliErrCount == 3) + { + Logger.LogWarn("B站弹幕解析持续失败,已抑制后续错误日志"); + biliErrCount++; + } + } + } + + /// + /// 处理客户端发送的B站WS数据 (解析AUTH认证包提取roomid) + /// B站客户端连接WS后会发送AUTH包: {uid,roomid,protover,platform,type,key} + /// + public void HandleSentData(WsMessageEventArgs e) + { + var buff = e.Payload; + if (buff == null || buff.Length < BiliSocketFrame.HEADER_SIZE) return; + + try + { + BiliSocketFrame frame; + if (!BiliSocketFrame.TryParse(buff, 0, buff.Length, out frame)) return; + + // 只关注认证请求包 (Operation=7) + if (frame.Operation != BiliOperation.AUTH) return; + + var payload = BiliSocketFrame.GetPayload(buff, 0, frame); + if (payload == null || payload.Length == 0) return; + + var json = System.Text.Encoding.UTF8.GetString(payload); + var jobj = JObject.Parse(json); + var roomIdToken = jobj["roomid"]; + if (roomIdToken == null) return; + + // roomid可能是整数或字符串 + string roomId = roomIdToken.Type == JTokenType.Integer + ? roomIdToken.Value().ToString() + : roomIdToken.Value() ?? ""; + + if (!string.IsNullOrWhiteSpace(roomId)) + { + CurrentRoomId = roomId; + Logger.LogInfo($"B站认证包解析成功,当前房间ID: {roomId}"); + } + } + catch (Exception ex) + { + Logger.LogWarn($"解析B站AUTH认证包失败: {ex.Message}"); + } + } + + private static int biliErrCount = 0; + + /// + /// 递归解析缓冲区中可能包含的多个B站数据包 + /// + private void ProcessBuffer(byte[] buffer, int offset, int count, string processName) + { + int processed = 0; + while (processed + BiliSocketFrame.HEADER_SIZE <= count) + { + BiliSocketFrame frame; + if (!BiliSocketFrame.TryParse(buffer, offset + processed, count - processed, out frame)) + { + break; + } + + // 包长超过剩余缓冲区,说明数据不完整,等下次再处理 + if (processed + frame.PacketLength > count) + { + break; + } + + var payload = BiliSocketFrame.GetPayload(buffer, offset + processed, frame); + processed += (int)frame.PacketLength; + + // 只处理服务器推送的消息和心跳回复 + if (frame.Operation == BiliOperation.HEARTBEAT_REPLY) + { + // 心跳回复: body是4字节big-endian人气值 + if (payload != null && payload.Length >= 4) + { + var online = (long)BiliSocketFrame.ReadUInt32BE(payload, 0); + var stats = new BiliRoomStats { Online = online, Watched = 0 }; + OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, stats)); + } + continue; + } + + if (frame.Operation != BiliOperation.MESSAGE) continue; + + // 根据协议版本解压 + byte[] jsonBytes; + switch (frame.ProtocolVersion) + { + case BiliProtocolVersion.PLAIN: + case BiliProtocolVersion.PLAIN_AUTH: + jsonBytes = payload; + break; + case BiliProtocolVersion.ZLIB: + jsonBytes = ZlibDecompress(payload); + break; + case BiliProtocolVersion.BROTLI: + jsonBytes = BrotliDecompress(payload); + break; + default: + jsonBytes = payload; + break; + } + + if (jsonBytes == null || jsonBytes.Length == 0) continue; + + // 解压后可能仍然是多个数据包 (Zlib/Brotli压缩的通常是合并包) + if (frame.ProtocolVersion == BiliProtocolVersion.ZLIB || + frame.ProtocolVersion == BiliProtocolVersion.BROTLI) + { + ProcessBuffer(jsonBytes, 0, jsonBytes.Length, processName); + continue; + } + + // 明文JSON: 解析为消息 + DispatchJsonMessage(jsonBytes, processName); + } + } + + /// + /// 将JSON字节解析为B站消息并触发对应事件 + /// + private void DispatchJsonMessage(byte[] jsonBytes, string processName) + { + string json; + try + { + json = System.Text.Encoding.UTF8.GetString(jsonBytes); + } + catch { return; } + + if (string.IsNullOrWhiteSpace(json)) return; + + JObject jobj; + try + { + jobj = JObject.Parse(json); + } + catch { return; } + + var cmdToken = jobj["cmd"]; + if (cmdToken == null) return; + string cmd = cmdToken.Value(); + if (string.IsNullOrWhiteSpace(cmd)) return; + + // 兼容cmd带数字后缀的版本,如 DANMU_MSG-4b1cb6f3 + int dashIdx = cmd.IndexOf('-'); + if (dashIdx > 0) cmd = cmd.Substring(0, dashIdx); + + switch (cmd) + { + case BiliCmd.DANMU_MSG: + { + var msg = ParseDanmuMsg(jobj); + if (msg != null) + { + OnChatMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, msg)); + } + break; + } + case BiliCmd.SEND_GIFT: + { + var msg = jobj["data"]?.ToObject(); + if (msg != null) + { + OnGiftMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, msg)); + } + break; + } + case BiliCmd.INTERACT_WORD: + { + var msg = jobj["data"]?.ToObject(); + if (msg != null) + { + // msg_type: 1=进入 2=关注 3=分享 + if (msg.MsgType == 3) + { + OnShareMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, msg)); + } + else + { + OnInteractMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, msg)); + } + } + break; + } + case BiliCmd.LIKE_INFO_V3_CLICK: + { + var msg = jobj["data"]?.ToObject(); + if (msg != null) + { + OnLikeMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, msg)); + } + break; + } + case BiliCmd.ONLINE_RANK_COUNT: + { + var count = jobj["data"]?["count"]?.Value() ?? 0; + var stats = new BiliRoomStats { Online = count, Watched = 0 }; + OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, stats)); + break; + } + case BiliCmd.WATCHED_CHANGE: + { + var num = jobj["data"]?["num"]?.Value() ?? 0; + var stats = new BiliRoomStats { Online = 0, Watched = num }; + OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, stats)); + break; + } + case BiliCmd.ROOM_REAL_TIME_MESSAGE_UPDATE: + { + // 同时包含在线人数和看过人数 + var online = jobj["data"]?["online"]?.Value() ?? 0; + var watched = jobj["data"]?["watched_show"]?["num"]?.Value() ?? 0; + var stats = new BiliRoomStats { Online = online, Watched = watched }; + OnRoomStatsMessage?.Invoke(this, new BiliRoomMessageEventArgs(processName, stats)); + break; + } + default: + // 未知消息类型忽略 + break; + } + } + + /// + /// 解析DANMU_MSG消息: info[1]=内容, info[2]=[uid,uname,isAdmin,rank,?anchor] + /// + private BiliDanmuMsg ParseDanmuMsg(JObject jobj) + { + try + { + var info = jobj["info"] as JArray; + if (info == null || info.Count < 3) return null; + + var content = info[1]?.Value() ?? ""; + var userInfo = info[2] as JArray; + if (userInfo == null || userInfo.Count < 2) return null; + + var msg = new BiliDanmuMsg + { + Content = content, + Uid = userInfo[0]?.Value() ?? 0, + Uname = userInfo[1]?.Value() ?? "", + IsAdmin = userInfo.Count > 2 && userInfo[2]?.Value() == 1, + IsAnchor = userInfo.Count > 4 && userInfo[4] != null && userInfo[4].Type != JTokenType.Null + }; + return msg; + } + catch + { + return null; + } + } + + /// Zlib解压 + private byte[] ZlibDecompress(byte[] data) + { + if (data == null || data.Length == 0) return null; + try + { + using (var ms = new MemoryStream(data)) + using (var zlib = new DeflateStream(ms, CompressionMode.Decompress)) + using (var outMs = new MemoryStream()) + { + // zlib前2字节是header(0x78 0x9c等),DeflateStream需要跳过 + // 但 .NET Core 2.1+ 的 DeflateStream 可以处理 zlib header, .NET Framework 需要跳过 + if (data.Length >= 2 && data[0] == 0x78) + { + ms.Position = 2; + } + zlib.CopyTo(outMs); + return outMs.ToArray(); + } + } + catch (Exception ex) + { + Logger.LogWarn($"B站Zlib解压失败: {ex.Message}"); + return null; + } + } + + /// Brotli解压 (使用 BrotliSharpLib) + private byte[] BrotliDecompress(byte[] data) + { + if (data == null || data.Length == 0) return null; + try + { + using (var ms = new MemoryStream(data)) + using (var brotli = new BrotliSharpLib.BrotliStream(ms, CompressionMode.Decompress)) + using (var outMs = new MemoryStream()) + { + brotli.CopyTo(outMs); + return outMs.ToArray(); + } + } + catch (Exception ex) + { + Logger.LogWarn($"B站Brotli解压失败: {ex.Message}"); + return null; + } + } + } + + /// B站消息事件参数 + public class BiliRoomMessageEventArgs : EventArgs where T : class + { + public string Process { get; set; } + public T Message { get; set; } + + public BiliRoomMessageEventArgs() { } + + public BiliRoomMessageEventArgs(string process, T data) + { + this.Process = process; + this.Message = data; + } + } +} diff --git a/BarrageGrab/Server/KsBarrageGrab.cs b/BarrageGrab/Server/KsBarrageGrab.cs new file mode 100644 index 0000000..d587008 --- /dev/null +++ b/BarrageGrab/Server/KsBarrageGrab.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text.RegularExpressions; +using BarrageGrab.Modles.KsProtoEntity; +using BarrageGrab.Proxy.ProxyEventArgs; +using ProtoBuf; + +namespace BarrageGrab +{ + public class KsBarrageGrab + { + // 2026-05 快手WS域名变更为 livejs-ws.kuaishou.cn + private readonly Regex kuaishouWsReg = new Regex(@"livejs-ws\.kuaishou\.cn|(?:livemate|pclivemate)\.pull\.yximgs\.com", RegexOptions.IgnoreCase); + private const string KUAISHOU_LIVE_HOST = "live.kuaishou.com"; + + public event EventHandler> OnChatMessage; + public event EventHandler> OnLikeMessage; + public event EventHandler> OnGiftMessage; + public event EventHandler> OnShareMessage; + public event EventHandler> OnRoomStatsMessage; + + public KsBarrageGrab() + { + } + + public bool IsKuaishouHost(string hostname) + { + if (string.IsNullOrWhiteSpace(hostname)) return false; + hostname = hostname.Trim().ToLower(); + return kuaishouWsReg.IsMatch(hostname) || hostname == KUAISHOU_LIVE_HOST; + } + + public bool IsKuaishouWebSocket(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) return false; + return kuaishouWsReg.IsMatch(uri); + } + + public void HandleWebSocketData(WsMessageEventArgs e) + { + var buff = e.Payload; + if (buff == null || buff.Length == 0) return; + + try + { + var socketMsg = Serializer.Deserialize(new ReadOnlyMemory(buff)); + if (socketMsg == null) return; + + if (socketMsg.PayloadType != KsPayloadType.SC_FEED_PUSH) return; + + byte[] payload = socketMsg.Payload; + if (socketMsg.CompressionType == KsCompressionType.GZIP) + { + payload = Decompress(payload); + } + + var feedPush = Serializer.Deserialize(new ReadOnlyMemory(payload)); + if (feedPush == null) return; + + foreach (var comment in feedPush.CommentFeeds) + { + OnChatMessage?.Invoke(this, new KsRoomMessageEventArgs(e.ProcessName, comment)); + } + + foreach (var like in feedPush.LikeFeeds) + { + OnLikeMessage?.Invoke(this, new KsRoomMessageEventArgs(e.ProcessName, like)); + } + + foreach (var gift in feedPush.GiftFeeds) + { + OnGiftMessage?.Invoke(this, new KsRoomMessageEventArgs(e.ProcessName, gift)); + } + + foreach (var share in feedPush.ShareFeeds) + { + OnShareMessage?.Invoke(this, new KsRoomMessageEventArgs(e.ProcessName, share)); + } + + OnRoomStatsMessage?.Invoke(this, new KsRoomMessageEventArgs(e.ProcessName, feedPush)); + } + catch (Exception ex) + { + Logger.LogError(ex, $"处理快手弹幕数据包时出错:{ex.Message}"); + } + } + + private byte[] Decompress(byte[] zippedData) + { + using (var ms = new MemoryStream(zippedData)) + using (var gzip = new GZipStream(ms, CompressionMode.Decompress)) + using (var outBuffer = new MemoryStream()) + { + byte[] block = new byte[1024]; + while (true) + { + int bytesRead = gzip.Read(block, 0, block.Length); + if (bytesRead <= 0) break; + outBuffer.Write(block, 0, bytesRead); + } + return outBuffer.ToArray(); + } + } + } + + public class KsRoomMessageEventArgs : EventArgs where T : class + { + public string Process { get; set; } + public T Message { get; set; } + + public KsRoomMessageEventArgs() { } + + public KsRoomMessageEventArgs(string process, T data) + { + this.Process = process; + this.Message = data; + } + } +} diff --git a/BarrageGrab/Server/WsBarrageServer.cs b/BarrageGrab/Server/WsBarrageServer.cs index 12b99d2..24345b0 100644 --- a/BarrageGrab/Server/WsBarrageServer.cs +++ b/BarrageGrab/Server/WsBarrageServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Drawing; @@ -39,7 +39,9 @@ namespace BarrageGrab ConcurrentDictionary> giftCountCache = new ConcurrentDictionary>();//礼物计数缓存 Timer dieout = new Timer(10000);//离线客户端清理计时器 Timer giftCountTimer = new Timer(10000);//礼物缓存清理计时器 - WssBarrageGrab grab = new WssBarrageGrab();//弹幕解析器核心 + WssBarrageGrab grab = new WssBarrageGrab();//抖音弹幕解析器核心 + KsBarrageGrab ksGrab = new KsBarrageGrab();//快手弹幕解析器核心 + BiliBarrageGrab biliGrab = new BiliBarrageGrab();//B站弹幕解析器核心 AppSetting Appsetting = AppSetting.Current;//全局配置文件实例 static int printCount = 0; //控制台输出计数,用于判断清理控制台 @@ -92,6 +94,43 @@ namespace BarrageGrab this.grab.OnControlMessage += Grab_OnControlMessage; this.grab.Proxy.OnLiveCompanAction += Proxy_OnLiveCompanAction; + //快手事件订阅 + this.ksGrab.OnChatMessage += KsGrab_OnChatMessage; + this.ksGrab.OnLikeMessage += KsGrab_OnLikeMessage; + this.ksGrab.OnGiftMessage += KsGrab_OnGiftMessage; + this.ksGrab.OnShareMessage += KsGrab_OnShareMessage; + this.ksGrab.OnRoomStatsMessage += KsGrab_OnRoomStatsMessage; + + //B站事件订阅 + this.biliGrab.OnChatMessage += BiliGrab_OnChatMessage; + this.biliGrab.OnLikeMessage += BiliGrab_OnLikeMessage; + this.biliGrab.OnGiftMessage += BiliGrab_OnGiftMessage; + this.biliGrab.OnInteractMessage += BiliGrab_OnInteractMessage; + this.biliGrab.OnShareMessage += BiliGrab_OnShareMessage; + this.biliGrab.OnRoomStatsMessage += BiliGrab_OnRoomStatsMessage; + + //多平台WS数据路由 - 将代理收到的WS数据按平台分发到对应解析器 + this.grab.Proxy.OnWebSocketData += (s, e) => + { + if (e.Platform == LivePlatform.Kuaishou) + { + this.ksGrab.HandleWebSocketData(e); + } + else if (e.Platform == LivePlatform.Bilibili) + { + this.biliGrab.HandleWebSocketData(e); + } + }; + + //B站发送数据路由 - 解析AUTH认证包提取roomid + this.grab.Proxy.OnWebSocketSentData += (s, e) => + { + if (e.Platform == LivePlatform.Bilibili) + { + this.biliGrab.HandleSentData(e); + } + }; + this.socketServer = socket; //dieout.Start(); giftCountTimer.Start(); @@ -633,6 +672,319 @@ namespace BarrageGrab } } + #region 快手消息处理 + + private MsgUser KsUserToMsgUser(Modles.KsProtoEntity.KsSimpleUserInfo user) + { + if (user == null) return null; + return new MsgUser() + { + Id = long.TryParse(user.PrincipalId, out var pid) ? pid : 0, + DisplayId = user.PrincipalId ?? "", + Nickname = user.UserName ?? "用户" + user.PrincipalId, + HeadImgUrl = user.HeadUrl ?? "", + Gender = 0, + Level = 0, + PayLevel = 0, + ShortId = 0, + }; + } + + private string KsRoomId; + + private void KsGrab_OnChatMessage(object sender, KsRoomMessageEventArgs e) + { + var feed = e.Message; + var enty = new Msg() + { + MsgId = long.TryParse(feed.Id, out var id) ? id : DateTime.Now.Ticks, + User = KsUserToMsgUser(feed.User), + Content = feed.Content ?? "", + RoomId = KsRoomId ?? "", + WebRoomId = KsRoomId ?? "", + Appid = "0" + }; + + var msgType = PackMsgType.弹幕消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Kuaishou); + Broadcast(pack); + } + + private void KsGrab_OnLikeMessage(object sender, KsRoomMessageEventArgs e) + { + var feed = e.Message; + var enty = new LikeMsg() + { + MsgId = long.TryParse(feed.Id, out var id) ? id : DateTime.Now.Ticks, + User = KsUserToMsgUser(feed.User), + Content = $"{feed.User?.UserName ?? "用户"} 为主播点了赞", + Count = 1, + Total = 0, + RoomId = KsRoomId ?? "", + WebRoomId = KsRoomId ?? "", + Appid = "0" + }; + + var msgType = PackMsgType.点赞消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Kuaishou); + Broadcast(pack); + } + + private void KsGrab_OnGiftMessage(object sender, KsRoomMessageEventArgs e) + { + var feed = e.Message; + var enty = new GiftMsg() + { + MsgId = long.TryParse(feed.Id, out var id) ? id : DateTime.Now.Ticks, + User = KsUserToMsgUser(feed.User), + GiftId = feed.GiftId, + GiftName = KsGiftHelper.GetGiftName(feed.GiftId) ?? $"礼物#{feed.GiftId}", + GroupId = 0, + GiftCount = feed.ComboCount > 0 ? feed.ComboCount : 1, + RepeatCount = feed.ComboCount > 0 ? feed.ComboCount : 1, + DiamondCount = 0, + Combo = feed.ComboCount > 1, + Content = $"{feed.User?.UserName ?? "用户"} 送出 {KsGiftHelper.GetGiftName(feed.GiftId) ?? $"礼物#{feed.GiftId}"} x {feed.ComboCount}", + RoomId = KsRoomId ?? "", + WebRoomId = KsRoomId ?? "", + Appid = "0" + }; + + var msgType = PackMsgType.礼物消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Kuaishou); + Broadcast(pack); + } + + private void KsGrab_OnShareMessage(object sender, KsRoomMessageEventArgs e) + { + var feed = e.Message; + var enty = new ShareMessage() + { + MsgId = long.TryParse(feed.Id, out var id) ? id : DateTime.Now.Ticks, + User = KsUserToMsgUser(feed.User), + Content = $"{feed.User?.UserName ?? "用户"} 分享了直播间", + ShareType = ShareType.未知, + RoomId = KsRoomId ?? "", + WebRoomId = KsRoomId ?? "", + Appid = "0" + }; + + var msgType = PackMsgType.直播间分享; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Kuaishou); + Broadcast(pack); + } + + private void KsGrab_OnRoomStatsMessage(object sender, KsRoomMessageEventArgs e) + { + var feed = e.Message; + var enty = new UserSeqMsg() + { + MsgId = DateTime.Now.Ticks, + Content = $"当前直播间人数 {feed.DisplayWatchingCount},点赞 {feed.DisplayLikeCount}", + OnlineUserCount = long.TryParse(feed.DisplayWatchingCount, out var watching) ? watching : 0, + TotalUserCount = 0, + OnlineUserCountStr = feed.DisplayWatchingCount ?? "0", + TotalUserCountStr = "0", + RoomId = KsRoomId ?? "", + WebRoomId = KsRoomId ?? "", + Appid = "0" + }; + + var msgType = PackMsgType.直播间统计; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Kuaishou); + Broadcast(pack); + } + + #endregion + + #region B站消息处理 + + private MsgUser BiliUserToMsgUser(Modles.BiliProtoEntity.BiliDanmuMsg user) + { + if (user == null) return null; + return new MsgUser() + { + Id = user.Uid, + DisplayId = user.Uid.ToString(), + Nickname = user.Uname ?? "用户" + user.Uid, + IsAdmin = user.IsAdmin, + IsAnchor = user.IsAnchor, + }; + } + + private MsgUser BiliUserToMsgUser(long uid, string uname) + { + return new MsgUser() + { + Id = uid, + DisplayId = uid.ToString(), + Nickname = string.IsNullOrWhiteSpace(uname) ? "用户" + uid : uname, + }; + } + + //B站弹幕 + private void BiliGrab_OnChatMessage(object sender, BiliRoomMessageEventArgs e) + { + var msg = e.Message; + var enty = new Msg() + { + MsgId = DateTime.Now.Ticks, + User = BiliUserToMsgUser(msg), + Content = msg.Content ?? "", + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + + var msgType = PackMsgType.弹幕消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + + //B站点赞 + private void BiliGrab_OnLikeMessage(object sender, BiliRoomMessageEventArgs e) + { + var msg = e.Message; + var enty = new LikeMsg() + { + MsgId = DateTime.Now.Ticks, + User = BiliUserToMsgUser(msg.Uid, msg.Uname), + Content = $"{msg.Uname ?? "用户"} 为主播点了赞", + Count = 1, + Total = 0, + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + + var msgType = PackMsgType.点赞消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + + //B站礼物 + private void BiliGrab_OnGiftMessage(object sender, BiliRoomMessageEventArgs e) + { + var msg = e.Message; + var count = msg.Num > 0 ? msg.Num : 1; + var enty = new GiftMsg() + { + MsgId = DateTime.Now.Ticks, + User = BiliUserToMsgUser(msg.Uid, msg.Uname), + GiftId = msg.GiftId, + GiftName = msg.GiftName ?? $"礼物#{msg.GiftId}", + GroupId = 0, + GiftCount = count, + RepeatCount = msg.SuperBatchNum > 0 ? msg.SuperBatchNum : count, + DiamondCount = (int)(msg.Price * 1000), + Combo = msg.ComboNum > 1, + ImgUrl = msg.GifUrl ?? "", + Content = $"{msg.Uname ?? "用户"} 送出 {msg.GiftName ?? $"礼物#{msg.GiftId}"} x {count}", + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + + var msgType = PackMsgType.礼物消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + + //B站进房/关注 + private void BiliGrab_OnInteractMessage(object sender, BiliRoomMessageEventArgs e) + { + var msg = e.Message; + // msg_type: 1=进入 2=关注 + if (msg.MsgType == 2) + { + var enty = new Msg() + { + MsgId = DateTime.Now.Ticks, + User = BiliUserToMsgUser(msg.Uid, msg.Uname), + Content = $"{msg.Uname ?? "用户"} 关注了主播", + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + var msgType = PackMsgType.关注消息; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + else + { + var enty = new Modles.JsonEntity.MemberMessage() + { + MsgId = DateTime.Now.Ticks, + User = BiliUserToMsgUser(msg.Uid, msg.Uname), + Content = $"{msg.Uname ?? "用户"} 来了直播间", + CurrentCount = 0, + EnterTipType = 0, + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + var msgType = PackMsgType.进直播间; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + } + + //B站分享 + private void BiliGrab_OnShareMessage(object sender, BiliRoomMessageEventArgs e) + { + var msg = e.Message; + var enty = new ShareMessage() + { + MsgId = DateTime.Now.Ticks, + User = BiliUserToMsgUser(msg.Uid, msg.Uname), + Content = $"{msg.Uname ?? "用户"} 分享了直播间", + ShareType = ShareType.未知, + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + + var msgType = PackMsgType.直播间分享; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + + //B站房间统计 + private void BiliGrab_OnRoomStatsMessage(object sender, BiliRoomMessageEventArgs e) + { + var msg = e.Message; + var enty = new UserSeqMsg() + { + MsgId = DateTime.Now.Ticks, + Content = $"当前直播间人数 {msg.Online},看过 {msg.Watched}", + OnlineUserCount = msg.Online, + TotalUserCount = msg.Watched, + OnlineUserCountStr = msg.Online.ToString(), + TotalUserCountStr = msg.Watched.ToString(), + RoomId = biliGrab.CurrentRoomId, + WebRoomId = biliGrab.CurrentRoomId, + Appid = "0" + }; + + var msgType = PackMsgType.直播间统计; + PrintMsg(enty, msgType); + var pack = new BarrageMsgPack(enty.ToJson(), msgType, e.Process, LivePlatform.Bilibili); + Broadcast(pack); + } + + #endregion + //监听用户连接 private void Listen(IWebSocketConnection socket) { diff --git a/BarrageGrab/Server/WssBarrageGrab.cs b/BarrageGrab/Server/WssBarrageGrab.cs index 8525b71..6e4ccf8 100644 --- a/BarrageGrab/Server/WssBarrageGrab.cs +++ b/BarrageGrab/Server/WssBarrageGrab.cs @@ -5,6 +5,7 @@ using System.IO.Compression; using System.Linq; using System.Text; using System.Threading.Tasks; +using BarrageGrab.Modles.JsonEntity; using BarrageGrab.Modles.ProtoEntity; using BarrageGrab.Proxy; using BarrageGrab.Proxy.ProxyEventArgs; @@ -24,7 +25,7 @@ namespace BarrageGrab /// /// 进入直播间 /// - public event EventHandler> OnMemberMessage; + public event EventHandler> OnMemberMessage; /// /// 关注 @@ -103,8 +104,10 @@ namespace BarrageGrab } //ws数据处理 + private static int douyinErrCount = 0; private void Proxy_OnWebSocketData(object sender, WsMessageEventArgs e) { + if (e.Platform != LivePlatform.Douyin) return; if (!appsetting.ProcessFilter.Contains(e.ProcessName)) return; var buff = e.Payload; if (buff.Length == 0) return; @@ -129,7 +132,17 @@ namespace BarrageGrab } catch (Exception ex) { - Logger.LogError(ex, $"处理弹幕数据包时出错:{ex.Message}"); + // 限流:同类错误最多打印3次,防止刷屏和连锁崩溃 + if (douyinErrCount < 3) + { + Logger.LogError(ex, $"处理抖音弹幕数据包时出错:{ex.Message}"); + douyinErrCount++; + } + if (douyinErrCount == 3) + { + Logger.LogWarn("抖音弹幕解析持续失败,已抑制后续错误日志(不影响快手弹幕)"); + douyinErrCount++; + } } } @@ -183,8 +196,8 @@ namespace BarrageGrab //来了 case "WebcastMemberMessage": { - var arg = Serializer.Deserialize(new ReadOnlyMemory(msg.Payload)); - this.OnMemberMessage?.Invoke(this, new RoomMessageEventArgs(processName, arg)); + var arg = Serializer.Deserialize(new ReadOnlyMemory(msg.Payload)); + this.OnMemberMessage?.Invoke(this, new RoomMessageEventArgs(processName, arg)); break; } //关注 diff --git a/BarrageGrab/Utility/BiliApiHelper.cs b/BarrageGrab/Utility/BiliApiHelper.cs new file mode 100644 index 0000000..06f874e --- /dev/null +++ b/BarrageGrab/Utility/BiliApiHelper.cs @@ -0,0 +1,271 @@ +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using BarrageGrab.Modles; +using Newtonsoft.Json.Linq; + +namespace BarrageGrab +{ + /// + /// B站直播API帮助类 + /// + internal class BiliApiHelper + { + private const string UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"; + + /// + /// 通过B站官方API获取房间信息 (推荐方式,稳定可靠) + /// 流程: get_info API 获取房间基本信息 -> Master/info API 获取主播昵称/头像 + /// + public static async Task GetRoomInfoByApi(string webRoomId, string cookie = null) + { + if (string.IsNullOrWhiteSpace(webRoomId)) return null; + + var client = CreateHttpClient(cookie); + + try + { + // 1. 获取房间信息 (get_info 支持短ID/长ID自动转换) + string roomInfoUrl = $"https://api.live.bilibili.com/room/v1/Room/get_info?room_id={webRoomId}"; + var roomResp = await client.GetStringAsync(roomInfoUrl); + var roomJson = JObject.Parse(roomResp); + + if (roomJson["code"]?.Value() != 0) + { + Logger.LogWarn($"B站 get_info API 返回错误: {roomJson["message"]?.Value()}"); + return null; + } + + var data = roomJson["data"]; + var info = new BiliRoomInfo + { + WebRoomId = webRoomId, + RoomId = data["room_id"]?.Value().ToString() ?? webRoomId, + ShortId = data["short_id"]?.Value().ToString() ?? "0", + Uid = data["uid"]?.Value().ToString() ?? "", + Title = data["title"]?.Value() ?? "", + IsLive = (data["live_status"]?.Value() ?? 0) == 1, + Online = data["online"]?.Value() ?? 0, + AreaName = data["area_name"]?.Value() ?? "", + ParentAreaName = data["parent_area_name"]?.Value() ?? "", + CoverUrl = data["user_cover"]?.Value() + ?? data["keyframe"]?.Value() + ?? "" + }; + + // 2. 获取主播昵称和头像 + if (!string.IsNullOrWhiteSpace(info.Uid)) + { + try + { + string masterUrl = $"https://api.live.bilibili.com/live_user/v1/Master/info?uid={info.Uid}"; + var masterResp = await client.GetStringAsync(masterUrl); + var masterJson = JObject.Parse(masterResp); + + if (masterJson["code"]?.Value() == 0) + { + var masterData = masterJson["data"]; + info.Nickname = masterData["info"]?["uname"]?.Value() ?? ""; + info.HeadUrl = masterData["info"]?["face"]?.Value() ?? ""; + } + } + catch (Exception ex) + { + Logger.LogWarn($"获取B站主播信息失败: {ex.Message}"); + } + } + + if (string.IsNullOrWhiteSpace(info.Nickname)) + { + info.Nickname = $"B站用户{info.Uid}"; + } + + Logger.LogInfo($"B站API获取房间信息成功: WebRoomId={webRoomId}, RoomId={info.RoomId}, Uname={info.Nickname}, Online={info.Online}, IsLive={info.IsLive}"); + return info; + } + catch (Exception ex) + { + Logger.LogError(ex, $"B站API获取房间信息失败: {ex.Message}"); + return null; + } + } + + /// + /// 从B站直播间页面HTML中解析房间信息 (备用方案) + /// 注意: B站前端改版后页面可能不再注入 __INITIAL_STATE__, 此方法可能失效 + /// + public static BiliRoomInfo ParseRoomInfoFromHtml(string html, string webRoomId) + { + var info = new BiliRoomInfo { WebRoomId = webRoomId }; + if (string.IsNullOrWhiteSpace(html)) return info; + + try + { + // 用括号配对算法可靠提取 __INITIAL_STATE__ 的 JSON + string jsonStr = ExtractJsonObject(html, "__INITIAL_STATE__"); + if (string.IsNullOrWhiteSpace(jsonStr)) + { + // B站改版后可能不再注入 __INITIAL_STATE__, 返回空对象由调用方走API + return info; + } + + JObject root; + try + { + root = JObject.Parse(jsonStr); + } + catch (Exception ex) + { + Logger.LogWarn($"B站 __INITIAL_STATE__ JSON 解析失败: {ex.Message}"); + return info; + } + + var roomInfoNode = root["roomInfo"]?["roomInfo"]; + var anchorInfoNode = root["roomInfo"]?["anchorInfo"]; + + if (roomInfoNode != null) + { + info.RoomId = TokenToString(roomInfoNode["room_id"], webRoomId); + info.ShortId = TokenToString(roomInfoNode["short_id"], "0"); + info.Uid = TokenToString(roomInfoNode["uid"], ""); + info.Title = roomInfoNode["title"]?.Value() ?? ""; + info.IsLive = (roomInfoNode["live_id"]?.Value() ?? 0) != 0; + info.Online = roomInfoNode["online"]?.Value() ?? 0; + info.AreaName = roomInfoNode["area_name"]?.Value() ?? ""; + info.ParentAreaName = roomInfoNode["parent_area_name"]?.Value() ?? ""; + info.CoverUrl = roomInfoNode["cover"]?.Value() + ?? roomInfoNode["keyframe"]?.Value() + ?? ""; + } + + if (anchorInfoNode != null) + { + var baseInfo = anchorInfoNode["base_info"]; + info.Nickname = baseInfo?["uname"]?.Value() + ?? anchorInfoNode["uname"]?.Value() + ?? ""; + info.HeadUrl = baseInfo?["face"]?.Value() + ?? anchorInfoNode["face"]?.Value() + ?? ""; + + var relationInfo = anchorInfoNode["relation_info"]; + if (relationInfo != null) + { + info.Attention = relationInfo["attribute"]?.Value() ?? 0; + } + } + + Logger.LogInfo($"B站HTML解析房间成功: WebRoomId={webRoomId}, RoomId={info.RoomId}, Uname={info.Nickname}"); + } + catch (Exception ex) + { + Logger.LogWarn($"解析B站页面房间信息时出错: {ex.Message}"); + } + + return info; + } + + /// + /// 从HTML中提取 __INITIAL_STATE__ 后的完整JSON对象 (括号配对算法) + /// 不受 < 字符或 ;(function 等干扰, 比正则更可靠 + /// + private static string ExtractJsonObject(string html, string marker) + { + int idx = html.IndexOf(marker); + if (idx < 0) return null; + + // 找到 = 后面的第一个 { + int eqIdx = html.IndexOf('=', idx); + if (eqIdx < 0) return null; + + int braceIdx = html.IndexOf('{', eqIdx); + if (braceIdx < 0) return null; + + // 逐字符扫描计算 { } 嵌套层级, 同时处理字符串和转义 + int depth = 0; + bool inString = false; + bool escape = false; + + for (int i = braceIdx; i < html.Length; i++) + { + char c = html[i]; + + if (escape) { escape = false; continue; } + if (c == '\\') { escape = true; continue; } + if (c == '"') { inString = !inString; continue; } + if (inString) continue; + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) + { + return html.Substring(braceIdx, i - braceIdx + 1); + } + } + } + + return null; + } + + /// JSON token 转字符串 (兼容整数/字符串类型) + private static string TokenToString(JToken token, string defaultValue) + { + if (token == null) return defaultValue; + return token.Type == JTokenType.Integer + ? token.Value().ToString() + : token.Value() ?? defaultValue; + } + + private static HttpClient CreateHttpClient(string cookie) + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", UA); + client.DefaultRequestHeaders.Add("Accept", "application/json, text/plain, */*"); + client.DefaultRequestHeaders.Add("Accept-Language", "zh-CN,zh;q=0.9"); + client.DefaultRequestHeaders.Add("Referer", "https://live.bilibili.com/"); + if (!string.IsNullOrWhiteSpace(cookie)) + { + client.DefaultRequestHeaders.Add("Cookie", cookie); + } + return client; + } + } + + /// + /// B站房间信息 + /// + public class BiliRoomInfo + { + /// 用户访问的房间号 (URL中的ID, 可能是短ID或真实room_id) + public string WebRoomId { get; set; } + /// 真实的房间长ID + public string RoomId { get; set; } + /// 短ID (无短ID时为"0") + public string ShortId { get; set; } + /// 主播UID + public string Uid { get; set; } + /// 直播间标题 + public string Title { get; set; } + /// 主播昵称 + public string Nickname { get; set; } + /// 主播头像 + public string HeadUrl { get; set; } + /// 直播间封面 + public string CoverUrl { get; set; } + /// 是否直播中 + public bool IsLive { get; set; } + /// 在线人数 + public long Online { get; set; } + /// 子分区名 + public string AreaName { get; set; } + /// 父分区名 + public string ParentAreaName { get; set; } + /// 关注状态 (0未关注 2已关注 6已互关) + public int Attention { get; set; } + /// 直播间地址 + public string LiveUrl { get; set; } + } +} diff --git a/BarrageGrab/Utility/KsApiHelper.cs b/BarrageGrab/Utility/KsApiHelper.cs new file mode 100644 index 0000000..ce09322 --- /dev/null +++ b/BarrageGrab/Utility/KsApiHelper.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using BarrageGrab.Modles; +using Newtonsoft.Json.Linq; + +namespace BarrageGrab +{ + internal class KsApiHelper + { + public static async Task GetRoomInfo(string userId, string cookie = null) + { + string url = $"https://live.kuaishou.com/u/{userId}"; + + var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"); + client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"); + client.DefaultRequestHeaders.Add("Accept-Language", "zh-CN,zh;q=0.9"); + if (!string.IsNullOrWhiteSpace(cookie)) + { + client.DefaultRequestHeaders.Add("Cookie", cookie); + } + + try + { + var response = await client.GetStringAsync(url); + return ParseRoomInfoFromHtml(response, userId); + } + catch (Exception ex) + { + Logger.LogError($"获取快手房间信息失败: {ex.Message}"); + return null; + } + } + + public static KsRoomInfo ParseRoomInfoFromHtml(string html, string userId) + { + var info = new KsRoomInfo { UserId = userId }; + + try + { + // 2026-05 快手页面结构变更: __INITIAL_STATE__ JSON 中的字段名和旧版本不同 + // liveStreamId → 嵌套在 "liveStream":{"id":"xxx"} + var liveStreamIdReg = new Regex(@"""liveStream""\s*:\s*\{[^}]*?""id""\s*:\s*""([^""]+)"""); + var liveStreamMatch = liveStreamIdReg.Match(html); + if (liveStreamMatch.Success) + { + info.LiveStreamId = liveStreamMatch.Groups[1].Value; + } + + // 直播封面 → liveStream.poster + var posterReg = new Regex(@"""liveStream""\s*:\s*\{[^}]*?""poster""\s*:\s*""([^""]+)"""); + var posterMatch = posterReg.Match(html); + if (posterMatch.Success) + { + info.CoverUrl = UnescapeJson(posterMatch.Groups[1].Value); + } + + // token + var tokenReg = new Regex(@"""token""\s*:\s*""([^""]*)"""); + var tokenMatch = tokenReg.Match(html); + if (tokenMatch.Success && !string.IsNullOrWhiteSpace(tokenMatch.Groups[1].Value)) + { + info.Token = tokenMatch.Groups[1].Value; + } + + // 主播昵称 → 嵌套在 "author":{"...","name":"xxx"} + var nickReg = new Regex(@"""author""\s*:\s*\{[^}]*?""name""\s*:\s*""([^""]+)"""); + var nickMatch = nickReg.Match(html); + if (nickMatch.Success) + { + info.Nickname = nickMatch.Groups[1].Value; + } + + // 主播头像 → author.avatar + var avatarReg = new Regex(@"""author""\s*:\s*\{[^}]*?""avatar""\s*:\s*""([^""]+)"""); + var avatarMatch = avatarReg.Match(html); + if (avatarMatch.Success) + { + info.HeadUrl = UnescapeJson(avatarMatch.Groups[1].Value); + } + + // 观看人数 → 现在是字符串如 "1万+",不是纯数字 + var watchingReg = new Regex(@"""watchingCount""\s*:\s*""([^""]+)"""); + var watchingMatch = watchingReg.Match(html); + if (watchingMatch.Success) + { + info.WatchingCountStr = watchingMatch.Groups[1].Value; + info.WatchingCount = ParseCount(info.WatchingCountStr); + } + + // 点赞数 → 字段名已变更为 liked + var likeReg = new Regex(@"""liked""\s*:\s*""([^""]+)"""); + var likeMatch = likeReg.Match(html); + if (likeMatch.Success) + { + info.LikeCountStr = likeMatch.Groups[1].Value; + info.LikeCount = ParseCount(info.LikeCountStr); + } + + // 直播状态 → 现在是 isLiving 布尔值 + var liveReg = new Regex(@"""isLiving""\s*:\s*(true|false)"); + var liveMatch = liveReg.Match(html); + if (liveMatch.Success) + { + info.IsLive = liveMatch.Groups[1].Value == "true"; + } + } + catch (Exception ex) + { + Logger.LogWarn($"解析快手页面房间信息时出错: {ex.Message}"); + } + + return info; + } + + // 将 "1万+" "2853" "6.7w" 等转为 long + private static long ParseCount(string str) + { + if (string.IsNullOrWhiteSpace(str)) return 0; + str = str.Trim(); + // 含"万" → 乘以10000 + if (str.Contains("万")) + { + var numStr = Regex.Replace(str, @"[^\d\.]", ""); + if (double.TryParse(numStr, out var d)) return (long)(d * 10000); + } + // 含"w" → 乘以10000 + if (str.ToLower().Contains("w")) + { + var numStr = Regex.Replace(str, @"[^\d\.]", ""); + if (double.TryParse(numStr, out var d)) return (long)(d * 10000); + } + // 纯数字 + if (long.TryParse(str, out var l)) return l; + return 0; + } + + // 反转义 JSON Unicode 转义序列 (\\u002F → /) + private static string UnescapeJson(string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + return Regex.Replace(str, @"\\u([0-9a-fA-F]{4})", m => + { + int code = int.Parse(m.Groups[1].Value, System.Globalization.NumberStyles.HexNumber); + return ((char)code).ToString(); + }); + } + } + + public class KsRoomInfo + { + public string UserId { get; set; } + public string LiveStreamId { get; set; } + public string Token { get; set; } + public string Nickname { get; set; } + public long WatchingCount { get; set; } + public long LikeCount { get; set; } + public bool IsLive { get; set; } + public string PageId { get; set; } = Guid.NewGuid().ToString("N").Substring(0, 16); + public string LiveUrl { get; set; } + // 字符串格式的原始值(用于展示 "1万+" 等) + public string WatchingCountStr { get; set; } + public string LikeCountStr { get; set; } + // 头像和封面 + public string HeadUrl { get; set; } + public string CoverUrl { get; set; } + } +} diff --git a/BarrageGrab/Utility/KsGiftHelper.cs b/BarrageGrab/Utility/KsGiftHelper.cs new file mode 100644 index 0000000..d7aa434 --- /dev/null +++ b/BarrageGrab/Utility/KsGiftHelper.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BarrageGrab +{ + /// + /// 快手礼物ID→名称映射表,通过代理嗅探自动收集 + /// + public static class KsGiftHelper + { + private static readonly ConcurrentDictionary GiftNames = new ConcurrentDictionary(); + private static readonly string GiftCachePath; + + static KsGiftHelper() + { + GiftCachePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ks_gifts.json"); + LoadMapping(); + } + + /// + /// 获取礼物名称,找不到返回 null + /// + public static string GetGiftName(uint giftId) + { + GiftNames.TryGetValue(giftId, out var name); + return name; + } + + /// + /// 尝试从任意 JSON 字符串中解析礼物映射 + /// 支持多种快手 API 返回格式: + /// - giftList: [{id:1, name:"小红花"}, ...] + /// - giftPanelList: [{id:1, giftName:"小红花"}, ...] + /// - allGifts: {"1": {name:"小红花"}, ...} + /// 返回是否解析到了新数据 + /// + public static bool TryParseGiftData(string json) + { + if (string.IsNullOrWhiteSpace(json)) return false; + json = json.Trim(); + if (json.Length < 10) return false; + + bool added = false; + + try + { + // 如果 JSON 以数组开头,需要特殊处理 + if (json.StartsWith("[")) + { + var arr = JArray.Parse(json); + added = ParseGiftArray(arr) || added; + return added; + } + + var obj = JObject.Parse(json); + + // 方式1: giftList → [{id, name}] 或 giftList → [{id, giftName}] + var giftList = obj["giftList"] as JArray; + if (giftList != null) + { + added = ParseGiftArray(giftList) || added; + } + + // 方式2: giftPanelList → [{id, name}] + var giftPanelList = obj["giftPanelList"] as JArray; + if (giftPanelList != null) + { + added = ParseGiftArray(giftPanelList) || added; + } + + // 方式3: allGifts → {"1": {name:"xxx"}, "2": {...}} + var allGifts = obj["allGifts"] as JObject; + if (allGifts != null) + { + foreach (var kv in allGifts) + { + if (uint.TryParse(kv.Key, out var gid)) + { + var name = kv.Value?["name"]?.Value() + ?? kv.Value?["giftName"]?.Value() + ?? kv.Value?["title"]?.Value(); + if (!string.IsNullOrWhiteSpace(name)) + { + added = UpdateMapping(gid, name) || added; + } + } + } + } + + // 方式4: data.giftList / data.giftPanelList / result.giftList + var data = obj["data"] ?? obj["result"]; + if (data is JObject dataObj) + { + var dataGiftList = dataObj["giftList"] as JArray; + if (dataGiftList != null) + { + added = ParseGiftArray(dataGiftList) || added; + } + var dataGiftPanel = dataObj["giftPanelList"] as JArray; + if (dataGiftPanel != null) + { + added = ParseGiftArray(dataGiftPanel) || added; + } + } + } + catch + { + // 不是有效 JSON,忽略 + } + + return added; + } + + private static bool ParseGiftArray(JArray arr) + { + bool added = false; + foreach (var item in arr) + { + if (item is JObject giftObj) + { + var idToken = giftObj["id"] ?? giftObj["giftId"]; + var nameToken = giftObj["giftName"] ?? giftObj["name"] ?? giftObj["title"] ?? giftObj["displayName"]; + + if (idToken != null && nameToken != null) + { + uint id = 0; + if (idToken.Type == JTokenType.Integer) + id = idToken.Value(); + else if (idToken.Type == JTokenType.String && uint.TryParse(idToken.Value(), out var sid)) + id = sid; + else + continue; + + var name = nameToken.Value(); + if (!string.IsNullOrWhiteSpace(name) && id > 0) + { + added = UpdateMapping(id, name) || added; + } + } + } + } + return added; + } + + private static bool UpdateMapping(uint giftId, string giftName) + { + if (GiftNames.TryGetValue(giftId, out var existing) && existing == giftName) + return false; + + GiftNames[giftId] = giftName; + Logger.LogInfo($"KS礼物映射: #{giftId} → {giftName}"); + return true; + } + + /// + /// 保存映射到本地文件 + /// + public static void SaveMapping() + { + if (GiftNames.Count == 0) return; + + try + { + var dict = new Dictionary(); + foreach (var kv in GiftNames) dict[kv.Key] = kv.Value; + var json = JsonConvert.SerializeObject(dict, Formatting.Indented); + File.WriteAllText(GiftCachePath, json); + Logger.LogInfo($"KS礼物映射已保存 ({GiftNames.Count} 个) → {GiftCachePath}"); + } + catch (Exception ex) + { + Logger.LogWarn($"保存KS礼物映射失败: {ex.Message}"); + } + } + + /// + /// 从本地文件加载映射 + /// + private static void LoadMapping() + { + try + { + if (!File.Exists(GiftCachePath)) return; + + var json = File.ReadAllText(GiftCachePath); + var dict = JsonConvert.DeserializeObject>(json); + if (dict != null) + { + foreach (var kv in dict) + { + GiftNames[kv.Key] = kv.Value; + } + Logger.LogInfo($"KS礼物映射已加载 ({GiftNames.Count} 个) → {GiftCachePath}"); + } + } + catch (Exception ex) + { + Logger.LogWarn($"加载KS礼物映射失败: {ex.Message}"); + } + } + + /// + /// 获取当前映射数量 + /// + public static int Count => GiftNames.Count; + } +} diff --git a/BarrageGrab/WssBarrageService.csproj b/BarrageGrab/WssBarrageService.csproj index cbdd9ff..72bcdc9 100644 --- a/BarrageGrab/WssBarrageService.csproj +++ b/BarrageGrab/WssBarrageService.csproj @@ -1,4 +1,4 @@ - + @@ -11,7 +11,7 @@ Properties BarrageGrab WssBarrageServer - v4.6.2 + v4.8 512 true false @@ -28,9 +28,10 @@ false false true - 0 + 1 1.0.0.%2a false + true true @@ -54,18 +55,18 @@ - LocalIntranet - - - false + true - Properties\app.manifest + logo.ico + + false + ..\packages\Acornima.1.1.0\lib\net462\Acornima.dll @@ -233,6 +234,12 @@ FormView.cs + + Form + + + Form + Form @@ -259,6 +266,13 @@ + + + + + + + @@ -267,9 +281,12 @@ WssBarrageService - + + PreserveNewest + + @@ -327,4 +344,4 @@ rmdir /s /q "$(TargetDir)crts" ) - \ No newline at end of file + diff --git a/BarrageGrab/jiakong_settings.json b/BarrageGrab/jiakong_settings.json new file mode 100644 index 0000000..1076135 --- /dev/null +++ b/BarrageGrab/jiakong_settings.json @@ -0,0 +1,13 @@ +{ + "gameExePath": "D:\\UnityProjects\\jiakong\\Fantasy.Map.Simulator\\FantasyMapSimulator.exe", + "gameWorkDir": "D:\\UnityProjects\\jiakong\\Fantasy.Map.Simulator", + "autoLaunchGame": true, + "configDir": "D:\\UnityProjects\\jiakong\\Fantasy.Map.Simulator\\BepInEx\\config", + "configFiles": { + "effect": "effect_config.txt", + "macro": "macro_config.txt", + "gift": "gift_config.txt", + "command": "command_config.txt", + "fkey": "fkey_config.txt" + } +} \ No newline at end of file