using AllBase; using AllBase.Model.Result; using Fiddler; using NaiBa.Util.Extension; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; namespace TemuKJD.BLL { internal static class Config { // ------- Cookie ------- // ------- 以下是协议里的固定常量,一般不用改 ------- public const string VideoHost = "https://westoregamma.video.qq.com"; public const string ShopHost = "https://store.weixin.qq.com"; public const int BizId = 50087; // 微信小店商品视频固定 bizId public const int AppType = 162; // 视频场景固定 apptype public const int FileType = 20302; // 视频场景固定 filetype public const int ChunkSize = 2 * 1024 * 1024; // 分片大小 2MB,与抓包一致 // 轮询转码结果的间隔与最大次数 public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); public const int MaxPollTimes = 30; } public class WXVideoUploader { private string Cookie = ""; // ------- 当前登录会话的 openid ------- private string OpenId = ""; // ------- 商品 ID, groupid ------- private string ProductId = ""; private string biz_magic = ""; private readonly HttpClient _http; public WXVideoUploader() { _http = new HttpClient(new HttpClientHandler { //Proxy = new WebProxy("http://127.0.0.1:8080"), //UseProxy = true, UseCookies=false, AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate }); } public WXVideoUploader(HttpClient http) { _http = http; } public MultiMediaDownloadUrlResp Upload(ShopLoginInfo shop, string productId, string videoPath) { if (!File.Exists(videoPath)) { throw new Exception("视频文件不存在"); } byte[] bs = File.ReadAllBytes(videoPath); return Upload(shop, productId, bs); } public MultiMediaDownloadUrlResp Upload(ShopLoginInfo shop, string productId, byte[] videoBytes) { var cookieLst = JsonHelper.DeserializeJsonToList(shop?.Cookies.ParseToString()); if (cookieLst == null) cookieLst = new List(); foreach (var cook in cookieLst) { if (cook.Name == "biz_magic") { biz_magic = cook.Value; } Cookie += cook.Name + "=" + cook.Value + "; "; } OpenId = shop.openid; ProductId = productId; int fileSize = videoBytes.Length; if (fileSize == 0) { throw new Exception("视频数据错误"); } // taskId 贯穿整个上传流程的 X-Arguments,fileId 与之相同 string taskId = Guid.NewGuid().ToString(); Debug.WriteLine($"[0/6] GetUploadTempImgAuth"); TempImgAuthResp authResp = GetUploadTempImgAuth(); Debug.WriteLine($"[1/6] initupload fileSize={fileSize} taskId={taskId}"); InitUploadResp initResp = InitUpload(taskId, fileSize); Debug.WriteLine($" -> fileName={initResp.FileName} uKey={initResp.UKey} videoId={initResp.VideoId}"); Debug.WriteLine("[2/6] uploadpart 分片上传..."); List finishParts = UploadParts(taskId, videoBytes, initResp.FileName, initResp.UKey, fileSize); Debug.WriteLine($" -> 共 {finishParts.Count} 片上传完成"); Debug.WriteLine("[3/6] finishupload 提交分片清单..."); FinishUpload(taskId, initResp.FileName, initResp.UKey, finishParts); Debug.WriteLine("[4/6] transcode 触发转码..."); Transcode(taskId, initResp.FileName); Debug.WriteLine("[5/6] getplayinfo 轮询转码结果..."); // 转码后的文件名规则: .f100001.mp4 string transcodedFileName = $"{initResp.VideoId}.f100001.mp4"; string playUrl = PollPlayUrl(taskId, transcodedFileName); Debug.WriteLine($" -> playUrl={playUrl}"); Debug.WriteLine("[6/6] getMultiMediaDownloadUrl 换取最终下载地址..."); MultiMediaDownloadUrlResp finalResult = GetMultiMediaDownloadUrl(playUrl); finalResult.VideoDuration = GetMp4Duration(videoBytes).Seconds; return finalResult; } // ------------------------------------------------------------------- // getUploadTempImgAuth // ------------------------------------------------------------------- private TempImgAuthResp GetUploadTempImgAuth(string uploadScene = "100") { string url = $"{Config.ShopHost}/shop-faas/mmecchannelspicture/cgi/getUploadTempImgAuth" + $"?token=&lang=zh_CN&uploadScene={uploadScene}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.TryAddWithoutValidation("Cookie", Cookie); req.Headers.TryAddWithoutValidation("potter-scene", "weixinShop"); req.Headers.TryAddWithoutValidation("wecom_magic", ""); req.Headers.TryAddWithoutValidation("supplier_magic", ""); req.Headers.TryAddWithoutValidation("talent_magic", ""); req.Headers.TryAddWithoutValidation("logistics_magic", ""); req.Headers.TryAddWithoutValidation("mcn_magic", ""); req.Headers.TryAddWithoutValidation("biz_magic", biz_magic); req.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); req.Headers.Referrer = new Uri($"{Config.ShopHost}/shop/goods/entry?productId={ProductId}"); using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); EnsureSuccessJson(resp, json); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code != 0) throw new Exception($"getUploadTempImgAuth 失败: {json}"); return parsed; } // ------------------------------------------------------------------- // 1. initupload // ------------------------------------------------------------------- private InitUploadResp InitUpload(string taskId, long fileSize) { var body = new { bizId = Config.BizId, fileUpInfo = new { fileId = taskId, fileType = "mp4", fileSize } }; using var req = BuildVideoRequest(HttpMethod.Post, "/initupload", taskId); req.Content = JsonContent(body); using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); EnsureSuccessJson(resp, json); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code != 0) throw new Exception($"initupload 失败: {json}"); return parsed; } // ------------------------------------------------------------------- // 2. uploadpart(按 2MB 分片,partnum 从 1 开始) // ------------------------------------------------------------------- private List UploadParts(string taskId, byte[] videoBytes, string fileName, string uKey, long fileSize) { var finishParts = new List(); using var fs = new MemoryStream(videoBytes); byte[] buffer = new byte[Config.ChunkSize]; int partNum = 1; long remaining = fileSize; while (remaining > 0) { int toRead = (int)Math.Min(Config.ChunkSize, remaining); int readTotal = 0; while (readTotal < toRead) { int n = fs.Read(buffer, readTotal, toRead - readTotal); if (n <= 0) throw new IOException("读取文件失败,文件可能被截断"); readTotal += n; } byte[] chunk = readTotal == buffer.Length ? buffer : buffer.Take(readTotal).ToArray(); string path = $"/uploadpart?filename={Uri.EscapeDataString(fileName)}&ukey={Uri.EscapeDataString(uKey)}&partnum={partNum}"; using var req = BuildVideoRequest(HttpMethod.Post, path, taskId); req.Content = new ByteArrayContent(chunk); req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); // 与抓包一致(实际发的是二进制) using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); EnsureSuccessJson(resp, json); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code != 0) throw new Exception($"uploadpart(part {partNum}) 失败: {json}"); Console.WriteLine($" part {partNum}: {readTotal} bytes, sha1(server)={parsed.PartSha}"); finishParts.Add(new FinishPart { PartNum = partNum, PartSha = parsed.PartSha }); remaining -= readTotal; partNum++; } return finishParts; } // ------------------------------------------------------------------- // 3. finishupload // ------------------------------------------------------------------- private void FinishUpload(string taskId, string fileName, string uKey, List finishParts) { var body = new { bizId = Config.BizId, fileName, uKey, finishParts, transInfo = new { snapshot = new[] { new { formatId = 1400 } }, // 封面截图 transcode = new[] { new { formatId = 100003 }, new { formatId = 100004 } } } }; using var req = BuildVideoRequest(HttpMethod.Post, "/finishupload", taskId); req.Content = JsonContent(body); using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); EnsureSuccessJson(resp, json); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code != 0) throw new Exception($"finishupload 失败: {json}"); } // ------------------------------------------------------------------- // 4. transcode(触发最终可播放版本 formatId=100001) // ------------------------------------------------------------------- private void Transcode(string taskId, string fileName) { var body = new { fileName, bizId = Config.BizId, transInfo = new { transcode = new[] { new { formatId = 100001 } } }, use_new_transcode = 1 }; using var req = BuildVideoRequest(HttpMethod.Post, "/transcode", taskId); req.Content = JsonContent(body); using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); EnsureSuccessJson(resp, json); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code != 0) throw new Exception($"transcode 失败: {json}"); } // ------------------------------------------------------------------- // 5. getplayinfo 轮询(转码未完成时返回 code=404,完成后 code=0 带 playUrl) // ------------------------------------------------------------------- private string PollPlayUrl(string taskId, string transcodedFileName) { for (int i = 0; i < Config.MaxPollTimes; i++) { string path = $"/getplayinfo?filename={Uri.EscapeDataString(transcodedFileName)}"; using var req = BuildVideoRequest(HttpMethod.Get, path, taskId); using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code == 0 && parsed.FormatList != null && parsed.FormatList.Count > 0) { string? url = parsed.FormatList[0].PlayUrl; if (!string.IsNullOrEmpty(url)) return url; } Console.WriteLine($" 转码中... ({i + 1}/{Config.MaxPollTimes}) code={parsed.Code}"); Thread.Sleep(Config.PollInterval); } throw new TimeoutException("等待转码超时,请增大 Config.MaxPollTimes 或检查视频云状态"); } // ------------------------------------------------------------------- // 6. store.weixin.qq.com getMultiMediaDownloadUrl // ------------------------------------------------------------------- private MultiMediaDownloadUrlResp GetMultiMediaDownloadUrl(string playUrl) { var url = $"{Config.ShopHost}/shop-faas/mmecchannelspicture/cgi/getMultiMediaDownloadUrl?token=&lang=zh_CN"; using var req = new HttpRequestMessage(HttpMethod.Post, url); req.Headers.TryAddWithoutValidation("Cookie", Cookie); req.Headers.TryAddWithoutValidation("potter-scene", "weixinShop"); // 抓包里这几个 magic 头是空字符串,但仍然带上 req.Headers.TryAddWithoutValidation("wecom_magic", ""); req.Headers.TryAddWithoutValidation("supplier_magic", ""); req.Headers.TryAddWithoutValidation("talent_magic", ""); req.Headers.TryAddWithoutValidation("logistics_magic", ""); req.Headers.TryAddWithoutValidation("mcn_magic", ""); req.Headers.TryAddWithoutValidation("biz_magic", biz_magic); req.Headers.Referrer = new Uri($"{Config.ShopHost}/shop/goods/entry?productId={ProductId}"); req.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); req.Content = JsonContent(new { playUrl }); using HttpResponseMessage resp = _http.SendAsync(req).GetAwaiter().GetResult(); string json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); EnsureSuccessJson(resp, json); var parsed = JsonConvert.DeserializeObject(json, JsonOpts)!; if (parsed.Code != 0) throw new Exception($"getMultiMediaDownloadUrl 失败: {json}"); return parsed; } // ------------------------------------------------------------------- // 公共辅助方法 // ------------------------------------------------------------------- /// /// 构造 westoregamma.video.qq.com 上的请求,统一带上 X-Arguments 等公共头 /// private HttpRequestMessage BuildVideoRequest(HttpMethod method, string pathAndQuery, string taskId) { var req = new HttpRequestMessage(method, Config.VideoHost + pathAndQuery); string xArguments = $"apptype={Config.AppType}&filetype={Config.FileType}&openid={OpenId}&groupid={ProductId}&taskid={taskId}"; req.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); req.Headers.TryAddWithoutValidation("X-Arguments", xArguments); req.Headers.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); req.Headers.Referrer = new Uri($"{Config.ShopHost}/"); req.Headers.TryAddWithoutValidation("Origin", Config.ShopHost); return req; } private static HttpContent JsonContent(object body) { string json = JsonConvert.SerializeObject(body); return new StringContent(json, Encoding.UTF8, "application/json"); } private static void EnsureSuccessJson(HttpResponseMessage resp, string json) { if (!resp.IsSuccessStatusCode) throw new Exception($"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}: {json}"); } #region 获取video时长 /// /// 从 MP4 字节数组中读取视频时长 /// /// 完整的 MP4 文件字节数组 /// 视频时长 private static TimeSpan GetMp4Duration(byte[] videoBytes) { if (videoBytes == null || videoBytes.Length < 8) throw new ArgumentException("无效的 MP4 数据", nameof(videoBytes)); int offset = 0; while (offset + 8 <= videoBytes.Length) { ulong boxSize = ReadUInt32BE(videoBytes, offset); string boxType = Encoding.ASCII.GetString( videoBytes, offset + 4, 4); int headerSize = 8; // Extended Size if (boxSize == 1) { if (offset + 16 > videoBytes.Length) throw new InvalidOperationException("MP4 Box 数据不完整"); boxSize = ReadUInt64BE(videoBytes, offset + 8); headerSize = 16; } // size == 0 表示一直到文件末尾 else if (boxSize == 0) { boxSize = (ulong)(videoBytes.Length - offset); } if (boxSize < (ulong)headerSize || boxSize > (ulong)(videoBytes.Length - offset)) { throw new InvalidOperationException( "无效的 MP4 Box: " + boxType); } // 找到 moov if (boxType == "moov") { int moovStart = offset + headerSize; int moovLength = checked((int)boxSize - headerSize); return ParseMoov( videoBytes, moovStart, moovLength); } offset += checked((int)boxSize); } throw new InvalidOperationException( "MP4 中没有找到 moov Box"); } private static TimeSpan ParseMoov( byte[] data, int start, int length) { int offset = start; int end = start + length; while (offset + 8 <= end) { ulong boxSize = ReadUInt32BE(data, offset); string boxType = Encoding.ASCII.GetString( data, offset + 4, 4); int headerSize = 8; if (boxSize == 1) { if (offset + 16 > end) throw new InvalidOperationException( "MP4 Box 数据不完整"); boxSize = ReadUInt64BE(data, offset + 8); headerSize = 16; } else if (boxSize == 0) { boxSize = (ulong)(end - offset); } if (boxSize < (ulong)headerSize || boxSize > (ulong)(end - offset)) { throw new InvalidOperationException( "无效的 MP4 Box: " + boxType); } // moov -> mvhd if (boxType == "mvhd") { return ParseMvhd( data, offset + headerSize, checked((int)boxSize - headerSize)); } offset += checked((int)boxSize); } throw new InvalidOperationException( "MP4 中没有找到 mvhd Box"); } private static TimeSpan ParseMvhd( byte[] data, int start, int length) { if (length < 20) throw new InvalidOperationException( "mvhd 数据不完整"); // version byte version = data[start]; // Version 0 if (version == 0) { // version + flags 4 // creation_time 4 // modification_time 4 // timescale 4 // duration 4 uint timescale = ReadUInt32BE( data, start + 12); uint duration = ReadUInt32BE( data, start + 16); if (timescale == 0) throw new InvalidOperationException( "MP4 timescale 为 0"); return TimeSpan.FromSeconds( (double)duration / timescale); } // Version 1 if (version == 1) { if (length < 32) throw new InvalidOperationException( "mvhd version 1 数据不完整"); // version + flags 4 // creation_time 8 // modification_time 8 // timescale 4 // duration 8 uint timescale = ReadUInt32BE( data, start + 20); ulong duration = ReadUInt64BE( data, start + 24); if (timescale == 0) throw new InvalidOperationException( "MP4 timescale 为 0"); return TimeSpan.FromSeconds( (double)duration / timescale); } throw new NotSupportedException( "不支持的 mvhd version: " + version); } private static uint ReadUInt32BE( byte[] data, int offset) { return (uint)( (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]); } private static ulong ReadUInt64BE( byte[] data, int offset) { uint high = ReadUInt32BE(data, offset); uint low = ReadUInt32BE(data, offset + 4); return ((ulong)high << 32) | low; } #endregion private static readonly JsonSerializerSettings JsonOpts = new JsonSerializerSettings { }; } internal class TempImgAuthResp { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("disK")] public string DisK { get; set; } = ""; [JsonProperty("disT")] public long DisT { get; set; } [JsonProperty("ext")] public string Ext { get; set; } = ""; [JsonProperty("url")] public string Url { get; set; } = ""; } internal class InitUploadResp { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("fileName")] public string FileName { get; set; } = ""; [JsonProperty("uKey")] public string UKey { get; set; } = ""; [JsonProperty("videoId")] public string VideoId { get; set; } = ""; [JsonProperty("msg")] public string Msg { get; set; } } internal class UploadPartResp { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("msg")] public string Msg { get; set; } [JsonProperty("partSha")] public string PartSha { get; set; } = ""; } internal class FinishPart { [JsonProperty("partNum")] public int PartNum { get; set; } [JsonProperty("partSha")] public string PartSha { get; set; } = ""; } internal class CodeMsgResp { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("msg")] public string Msg { get; set; } } internal class GetPlayInfoResp { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("msg")] public string Msg { get; set; } [JsonProperty("formatList")] public List? FormatList { get; set; } } internal class PlayFormat { [JsonProperty("formatId")] public int FormatId { get; set; } [JsonProperty("playUrl")] public string PlayUrl { get; set; } [JsonProperty("duration")] public double Duration { get; set; } [JsonProperty("width")] public int Width { get; set; } [JsonProperty("height")] public int Height { get; set; } } public class MultiMediaDownloadUrlResp { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("videoImgUrl")] public string VideoImgUrl { get; set; } [JsonProperty("downloadUrl")] public string DownloadUrl { get; set; } /// /// 时长 /// public int VideoDuration { get; set; } } }