using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Microsoft.Web.WebView2.Core; namespace WinFormsApp3 { /// /// 当前正在播放的视频地址。 /// /// 可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。 /// 只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。 /// 发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。 /// DASH 分离音轨地址;不为空时需要和 Url 一起下载后用 ffmpeg 合并。 public record VideoCandidate(string Url, string? PlaylistText = null, string? Referer = null, string? AudioUrl = null); public partial class Form1 : Form { private const string SnifferScript = @"Resources\media_sniffer.js"; private const string ButtonScript = @"Resources\video_button.js"; private const string GrabScript = @"Resources\video_grab.js"; private double currentZoomFactor = 1.3; private readonly List sniffedUrls = new(); private readonly List sniffedPlaylists = new(); /// 视频地址 → 发出请求的文档地址,用作下载时的 Referer。 private readonly Dictionary urlReferers = new(); private readonly Dictionary injectedScripts = new(); private CancellationTokenSource? downloadCts; private bool hooksAttached; /// 批量下载时用于识别“用户按了取消”,直接中止后续条目。 private bool downloadWasCanceled; public Form1() { InitializeComponent(); string css = @" .pt-top-nav { display: none !important; } .pt-head-bg { display: none !important; } .pt-navbg { display: none !important; } .erwm { display: none !important; } .pt-right { display: none !important; } .pt-copyright { display: none !important; } .pt-crumb { display: none !important; } .txtinfo { display: none !important; } .mys-wrapper { display: none !important; } .tools { display: none !important; } .headbox { display: none !important; } .setbox { display: none !important; } .foot { display: none !important; } "; if (File.Exists("css.txt")) { css = File.ReadAllText("css.txt"); } webView21.NavigationCompleted += async (sender, e) => { if (e.IsSuccess) { await InjectCssAsync(css); } }; urlTxt.Text = "http://www.luoqiuzww.cc"; } private async Task InjectCssAsync(string css) { if (File.Exists("css.txt")) { css = File.ReadAllText("css.txt"); } string script = $@" var style = document.createElement('style'); style.type = 'text/css'; style.innerHTML = `{css}`; document.head.appendChild(style); "; await webView21.CoreWebView2.ExecuteScriptAsync(script); } private void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) { if (e.IsSuccess) { urlTxt.Text = ((Microsoft.Web.WebView2.Core.CoreWebView2)sender).Source; // 小延迟确保DOM完全加载 Task.Delay(100).ContinueWith(_ => { this.Invoke((MethodInvoker)delegate { if (Math.Abs(webView21.ZoomFactor - currentZoomFactor) > 0.01f) { webView21.ZoomFactor = currentZoomFactor; } }); }); } } /// /// 顶层页面导航完成后重置嗅探结果:只保留当前页面里播过的地址, /// 免得长时间浏览后下载地址越攒越多。 /// private void OnMainNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e) { if (!e.IsSuccess) { return; } lock (sniffedUrls) sniffedUrls.Clear(); lock (urlReferers) urlReferers.Clear(); sniffedPlaylists.Clear(); } private async Task EnsureCoreAsync() { if (webView21.CoreWebView2 != null) { return; } try { // 初始化 CoreWebView2 await webView21.EnsureCoreWebView2Async(null); if (!hooksAttached && webView21.CoreWebView2 is { } core) { hooksAttached = true; core.FrameNavigationCompleted += OnNavigationCompleted; core.NavigationCompleted += OnNavigationCompleted; // 换了页面就把上一页嗅探到的地址清空,下载时只会拿到当前这页在播的视频 core.NavigationCompleted += OnMainNavigationCompleted; core.DOMContentLoaded += (sender, e) => webView21.ZoomFactor = currentZoomFactor; webView21.ZoomFactorChanged += (sender, e) => currentZoomFactor = webView21.ZoomFactor; // 页面里的嗅探脚本靠这两个事件把视频地址回传 core.WebMessageReceived += OnWebMessageReceived; // 站点自带的"下载"按钮(如抖音)也统一走另存为,避免存到看不见的默认目录 core.DownloadStarting += OnDownloadStarting; // 新窗口就地导航,保证嗅探脚本仍然在跑 core.NewWindowRequested += OnNewWindowRequested; } await InjectScriptsAsync(); } catch (Exception ex) { MessageBox.Show($"初始化 WebView2 失败: {ex.Message}"); } } /// /// 在文档创建阶段注入嗅探与悬浮按钮脚本,保证早于播放器脚本运行(抖音视频是翻页加载的,尤其需要早注入)。 /// private async Task InjectScriptsAsync() { var core = webView21.CoreWebView2; if (core == null) { return; } foreach (var file in new[] { SnifferScript, ButtonScript }) { if (injectedScripts.ContainsKey(file)) { continue; } string code = ReadScript(file); if (string.IsNullOrEmpty(code)) { continue; } string id = await core.AddScriptToExecuteOnDocumentCreatedAsync(code); if (!string.IsNullOrEmpty(id)) { injectedScripts[file] = id; } } } private async void InitializeWebView() { await EnsureCoreAsync(); if (webView21.CoreWebView2 == null) { return; } try { // 加载网页 webView21.CoreWebView2.Navigate(urlTxt.Text); } catch (Exception ex) { MessageBox.Show($"加载页面失败: {ex.Message}"); } } private void Form1_Load(object sender, EventArgs e) { if (File.Exists("currentLink.txt")) { urlTxt.Text = File.ReadAllText("currentLink.txt"); } // 打开就直接加载页面,不用先点一次“转到”才能看视频 InitializeWebView(); } private void Form1_FormClosing(object sender, FormClosingEventArgs e) { File.WriteAllText("currentLink.txt", urlTxt.Text); } private void cssBut_Click(object sender, EventArgs e) { ShowDialog("css.txt"); } private void addLinkBut_Click(object sender, EventArgs e) { File.AppendAllText("link.txt", "\r\n" + urlTxt.Text); } private void linkBut_Click(object sender, EventArgs e) { ShowDialog("link.txt"); } private void ShowDialog(string path) { Form2 form2 = new Form2(); form2.Path = path; form2.ShowDialog(); } private void displayBut_Click(object sender, EventArgs e) { urlTxt.Visible = !urlTxt.Visible; } private void goBut_Click(object sender, EventArgs e) { InitializeWebView(); } #region 视频下载 private static string ReadScript(string file) { try { string path = Path.IsPathRooted(file) ? file : Path.Combine(Application.StartupPath, file); return File.Exists(path) ? File.ReadAllText(path) : string.Empty; } catch { return string.Empty; } } private void SetStatus(string text) { if (IsHandleCreated && InvokeRequired) { BeginInvoke(new Action(() => statusLbl.Text = text)); } else { statusLbl.Text = text; } } /// /// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口, /// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。 /// private async Task OnUiAsync(Func> action) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); PreviewLog.Write($"OnUiAsync 排队 {action.Method.Name}"); try { BeginInvoke(new Action(async () => { try { T r = await action(); PreviewLog.Write($"OnUiAsync 完成 {action.Method.Name}"); tcs.SetResult(r); } catch (Exception ex) { tcs.SetException(ex); } })); } catch (Exception ex) { tcs.SetException(ex); } return await tcs.Task; } /// /// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度, /// 只留最近若干个,供“当前播放地址取不到时”兜底用。 /// private void AddSniffedUrl(string url, string? referer) { lock (sniffedUrls) { if (!sniffedUrls.Contains(url)) { sniffedUrls.Add(url); } while (sniffedUrls.Count > 50) sniffedUrls.RemoveAt(0); } if (!string.IsNullOrWhiteSpace(referer)) { lock (urlReferers) urlReferers[url] = referer; } } private string? RefererFor(string url, string? fallback) { lock (urlReferers) { if (urlReferers.TryGetValue(url, out var referer)) return referer; } return string.IsNullOrWhiteSpace(fallback) ? null : fallback; } private List SnapshotSniffedUrls() { lock (sniffedUrls) return sniffedUrls.ToList(); } /// /// 接收页面嗅探脚本回传的视频地址与播放列表文本。 /// private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e) { try { string json = e.TryGetWebMessageAsString(); JsonNode? node = JsonNode.Parse(json); string? type = node?["type"]?.GetValue(); switch (type) { case "media": { string? url = node?["url"]?.GetValue(); string? referer = node?["base"]?.GetValue(); if (!string.IsNullOrEmpty(url) && VideoDownloader.LooksLikeMedia(url)) { AddSniffedUrl(url, referer); if (VideoDownloader.IsHlsUrl(url)) { SetStatus("已嗅探到 m3u8,可点击下载"); } } break; } case "hlsText": { string? text = node?["text"]?.GetValue(); if (!string.IsNullOrEmpty(text) && text.Contains("#EXTM3U") && !sniffedPlaylists.Contains(text)) { sniffedPlaylists.Add(text); // 只留最近几份播放列表,够用又不会越积越多 while (sniffedPlaylists.Count > 10) sniffedPlaylists.RemoveAt(0); SetStatus("已抓到播放列表,可点击下载"); } break; } case "downloadClick": // 播放器右下角的悬浮按钮 _ = DownloadCurrentVideoAsync(); break; } } catch { // 非本程序约定的消息直接忽略 } } /// /// 站点自己发起的下载(例如抖音自带的下载入口)改为用户选择保存位置。 /// private void OnDownloadStarting(object? sender, CoreWebView2DownloadStartingEventArgs e) { string suggested = Path.GetFileName(e.DownloadOperation.ResultFilePath); if (string.IsNullOrWhiteSpace(suggested)) { suggested = "video.mp4"; } using SaveFileDialog dlg = new SaveFileDialog { Title = "保存视频", FileName = suggested, Filter = "视频文件|*.mp4;*.ts;*.mkv;*.flv;*.mov|所有文件|*.*" }; if (dlg.ShowDialog(this) != DialogResult.OK) { e.Cancel = true; SetStatus("已取消下载"); return; } string target = dlg.FileName; e.Handled = true; e.ResultFilePath = target; SetStatus($"正在下载 {Path.GetFileName(target)}"); CoreWebView2DownloadOperation operation = e.DownloadOperation; operation.BytesReceivedChanged += (s, ev) => { ulong? total = operation.TotalBytesToReceive; string totalText = total > 0 ? $" / {VideoDownloader.FormatSize((long)total!.Value)}" : string.Empty; SetStatus($"下载中 {VideoDownloader.FormatSize(operation.BytesReceived)}{totalText}"); }; operation.StateChanged += (s, ev) => { switch (operation.State) { case CoreWebView2DownloadState.Completed: SetStatus($"已保存 {target}"); break; case CoreWebView2DownloadState.Interrupted: SetStatus($"下载中断:{operation.InterruptReason}"); break; } }; } private void OnNewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs e) { // 抖音等站点的 window.open 在这里就地导航,避免新窗口没有嗅探脚本 e.Handled = true; webView21.CoreWebView2?.Navigate(e.Uri); } private async void dlBut_Click(object sender, EventArgs e) { await DownloadCurrentVideoAsync(); } private async void listBut_Click(object sender, EventArgs e) { await ShowVideoListAsync(); } /// /// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒), /// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。 /// private async Task ShowVideoListAsync() { if (webView21.CoreWebView2 == null) { SetStatus("页面还没加载好,请稍候"); return; } if (downloadCts != null) { SetStatus("已有下载在进行中,可先点“取消下载”"); return; } listBut.Enabled = false; try { SetStatus("正在识别本页视频…"); string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript()); (List items, HashSet currentUrls) = CollectCandidates(raw); if (items.Count == 0) { SetStatus("没识别到视频地址"); MessageBox.Show(this, "本页没有识别到可下载的视频地址。\n\n" + "常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n" + "2. 站点用了加密流(DRM),无法直接下载;\r\n" + "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。", "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 关键:列表窗口是模态对话框,实测对话框存在期间 WebView2 的异步回调 // (ExecuteScriptAsync / GetCookiesAsync)不会送达,要等窗口关闭才一次性返回。 // 所以所有依赖 WebView2 的数据必须在 ShowDialog 之前预取完毕, // 后台预览任务只做 ffmpeg 抽帧,不再碰 WebView2。 SetStatus("正在取登录态与播放器画面…"); Dictionary cookies = new Dictionary(); foreach (VideoCandidate item in items) { if (!cookies.ContainsKey(item.Url)) { cookies[item.Url] = await GetCookieHeaderAsync(item.Url); } } List canvasFrames = await CaptureCurrentFramesAsync(CancellationToken.None); Image? currentStrip = VideoListForm.ComposeFrames(canvasFrames); PreviewLog.Write($"预取完成 cookies={cookies.Count} canvas帧={canvasFrames.Count} strip={(currentStrip != null ? "ok" : "null")}"); SetStatus($"识别到 {items.Count} 个视频,正在生成截图…"); Func> factory = (c, ct) => BuildPreviewAsync(c, currentUrls.Contains(c.Url), currentStrip, cookies, ct); using VideoListForm form = new VideoListForm(items, currentUrls, factory); if (form.ShowDialog(this) != DialogResult.OK || form.SelectedCandidates.Count == 0) { SetStatus("已取消"); return; } using FolderBrowserDialog folderDlg = new FolderBrowserDialog { Description = $"选择保存文件夹(将下载 {form.SelectedCandidates.Count} 个视频)" }; if (folderDlg.ShowDialog(this) != DialogResult.OK) { SetStatus("已取消"); return; } await DownloadCandidatesBatchAsync(form.SelectedCandidates, folderDlg.SelectedPath); } catch (OperationCanceledException) { SetStatus("已取消"); } catch (Exception ex) { SetStatus($"识别失败:{ex.Message}"); MessageBox.Show(this, ex.Message, "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); } finally { listBut.Enabled = true; } } /// /// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条 /// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。 /// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。 /// private async Task<(Image? Image, string? Error)> BuildPreviewAsync( VideoCandidate candidate, bool isCurrent, Image? currentStrip, IReadOnlyDictionary cookies, CancellationToken ct) { string shortUrl = candidate.Url.Length > 70 ? candidate.Url[..70] : candidate.Url; PreviewLog.Write($"预览开始 isCurrent={isCurrent} url={shortUrl}"); // 只在内存里的播放列表文本没有可 seek 的地址,不抽帧 if (candidate.PlaylistText != null) { return (null, "内存播放列表,无法抽帧预览"); } if (isCurrent && currentStrip != null) { PreviewLog.Write("预览成功:播放器画面预取帧"); return (currentStrip, null); } try { string? referer = candidate.Referer ?? RefererFor(candidate.Url, null); cookies.TryGetValue(candidate.Url, out string? cookie); VideoDownloader downloader = new VideoDownloader(referer, cookie); PreviewLog.Write("ffmpeg 抽帧开始"); VideoDownloader.FrameExtraction result = await downloader.ExtractFramesAsync(candidate.Url, ct); Image? img = VideoListForm.ComposeFrames(result.Frames); PreviewLog.Write($"ffmpeg 抽帧结束 frames={result.Frames.Count} error={result.Error ?? "-"}"); return img != null ? (img, null) : (null, result.Error ?? "没抽到帧"); } catch (OperationCanceledException) { PreviewLog.Write("预览被取消"); throw; } catch (Exception ex) { PreviewLog.Write($"预览异常 {ex.GetType().Name}: {ex.Message}"); return (null, ex.Message); } } /// /// 从正在播放的 private async Task> CaptureCurrentFramesAsync(CancellationToken ct) { List frames = new List(); try { if (webView21.CoreWebView2 == null) return frames; // 后台线程不能直接碰 ExecuteScriptAsync,封送回 UI 线程; // 整条脚本再加 20 秒硬超时,页面播放器抽风时不能把预览任务永久挂住 using var overall = CancellationTokenSource.CreateLinkedTokenSource(ct); overall.CancelAfter(TimeSpan.FromSeconds(20)); string raw; try { raw = await OnUiAsync(() => webView21.CoreWebView2!.ExecuteScriptAsync(CurrentFramesScript)) .WaitAsync(overall.Token); } catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) { PreviewLog.Write($"播放器取帧脚本超时/取消:{ex.GetType().Name}"); return frames; } ct.ThrowIfCancellationRequested(); PreviewLog.Write($"播放器取帧脚本返回 {raw.Length} 字符"); string? inner = JsonSerializer.Deserialize(raw); if (string.IsNullOrEmpty(inner)) return frames; if (JsonNode.Parse(inner)?["frames"] is JsonArray arr) { foreach (JsonNode? item in arr) { string? dataUrl = item?.GetValue(); int comma = dataUrl?.IndexOf(',') ?? -1; if (dataUrl == null || comma < 0) continue; try { frames.Add(Convert.FromBase64String(dataUrl[(comma + 1)..])); } catch { /* 坏帧跳过 */ } } } } catch (OperationCanceledException) { throw; } catch { // 播放器截图失败就退回 ffmpeg 远程抽帧 } return frames; } private const string CurrentFramesScript = @" (async function(){ function findVideo(){ var vids=Array.prototype.slice.call(document.querySelectorAll('video')); for(var i=0;i0) return vids[i]; } for(var j=0;j0) return vids[j]; } return vids[0]||null; } var v=findVideo(); if(!v) return JSON.stringify({frames:[]}); var times=[2,7,12], frames=[], wasPaused=v.paused, t0=v.currentTime; try{ v.pause(); for(var k=0;kv.duration-0.2) t=v.duration>1?v.duration-0.5:0; var ok=true; try{ await new Promise(function(res,rej){ var done=false; var to=setTimeout(function(){ if(!done){done=true;rej('t');} },3000); v.onseeked=function(){ if(!done){done=true;clearTimeout(to);res();} }; try{ v.currentTime=t; }catch(e){ if(!done){done=true;clearTimeout(to);rej('s');} } }); }catch(e){ ok=false; } if(!ok) break; try{ var c=document.createElement('canvas'); var w=v.videoWidth||480, h=v.videoHeight||270; var sc=Math.min(1,480/w); c.width=Math.round(w*sc); c.height=Math.round(h*sc); c.getContext('2d').drawImage(v,0,0,c.width,c.height); frames.push(c.toDataURL('image/jpeg',0.85)); }catch(e){ frames=[]; break; } } } finally { // 注意:这里绝不能 await v.play()——抖音播放器会干预 play 的 Promise, // 一旦 pending 不结束整段脚本永不返回,截图任务会永久挂死 try{ v.currentTime=t0; if(!wasPaused){ try{ v.play().catch(function(){}); }catch(e){} } }catch(e){} } return JSON.stringify({frames:frames}); })()"; /// /// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并; /// 某条失败时询问是否继续,用户点取消则中止剩余条目。 /// private async Task DownloadCandidatesBatchAsync(List items, string folder) { int succeeded = 0; List failures = new List(); VideoDownloader namer = new VideoDownloader(RefererFor(items[0].Url, webView21.CoreWebView2?.Source)); for (int i = 0; i < items.Count; i++) { VideoCandidate item = items[i]; string fileName = namer.SuggestedFileName(item.Url, PageTitle); string filePath = UniqueFilePath(folder, fileName); SetStatus($"批量下载 {i + 1}/{items.Count}:{fileName}"); downloadWasCanceled = false; string? failure = await TryDownloadAsync(item, filePath, promptOpenFolder: false); if (downloadWasCanceled) break; // 用户取消整个批次 if (failure == null) { succeeded++; } else { failures.Add($"{fileName}:{failure}"); if (i < items.Count - 1 && MessageBox.Show(this, $"第 {i + 1} 个下载失败:\n{failure}\n\n跳过它继续下载剩下的吗?", "批量下载", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) { break; } } } string summary = $"成功 {succeeded} 个,失败 {failures.Count} 个(共 {items.Count})。"; SetStatus($"批量下载完成:{summary}"); if (failures.Count > 0) { summary += "\n\n" + string.Join("\n", failures.Take(8)) + (failures.Count > 8 ? "\n…" : string.Empty); } MessageBox.Show(this, summary, "批量下载", MessageBoxButtons.OK, failures.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning); if (succeeded > 0) OpenFolder(folder); } /// /// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。 /// private async Task DownloadCurrentVideoAsync() { if (webView21.CoreWebView2 == null) { SetStatus("页面还没加载好,请稍候"); return; } if (downloadCts != null) { SetStatus("已有下载在进行中,可先点“取消下载”"); return; } dlBut.Enabled = false; try { SetStatus("正在识别当前视频…"); string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript()); VideoCandidate? candidate = CollectCurrentCandidate(raw); if (candidate == null) { SetStatus("没识别到视频地址"); MessageBox.Show(this, "没有抓到当前正在播放的视频地址。\n\n" + "常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n" + "2. 站点用了加密流(DRM),无法直接下载;\r\n" + "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。", "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } await DownloadVideoAsync(candidate); } catch (OperationCanceledException) { SetStatus("已取消"); } catch (Exception ex) { SetStatus($"识别失败:{ex.Message}"); MessageBox.Show(this, ex.Message, "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Warning); } finally { dlBut.Enabled = true; } } /// /// 抓取脚本兜底版本:资源文件丢失时仍能取到当前 video 标签的播放地址。 /// private static string BuildGrabScript() { string code = ReadScript(GrabScript); if (!string.IsNullOrEmpty(code)) { return code; } return "(function(){var v=document.querySelector('video');" + "return JSON.stringify({current:v?(v.currentSrc||v.src):'',title:document.title,pageUrl:location.href," + "sniffed:(window.__mediaUrls||[]).slice(-60),m3u8Blobs:(window.__m3u8Blobs||[]).slice(-1)});})()"; } /// /// 只取当前正在播放的那一个视频地址,按四层递降: /// 1. video 标签自己的播放源(最准确,就是正在播的这个); /// 2. DASH 分离轨(抖音这类 blob+MSE):视频轨 + 音频轨配对,下载后合并; /// 3. 播放器在 iframe 里取不到源时,退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算); /// 4. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。 /// private VideoCandidate? CollectCurrentCandidate(string grabResultJson) { GrabInfo g = ParseGrab(grabResultJson); string pageUrl = g.PageUrl; List sniffed = g.Sniffed; string dashVideo = g.DashVideo; string dashAudio = g.DashAudio; string videoId = g.VideoId; string current = g.Current; // 第一层:video 标签自己的播放源 if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current)) { return new VideoCandidate(current, null, pageUrl); } // 第二层:DASH 分离轨配对(抖音)—— JS 端已按 videoId 过滤 if (!string.IsNullOrEmpty(dashVideo) && VideoDownloader.LooksLikeMedia(dashVideo)) { return new VideoCandidate(dashVideo, null, RefererFor(dashVideo, pageUrl), string.IsNullOrEmpty(dashAudio) ? null : dashAudio); } // 第三层:从嗅探结果里找当前视频的地址 List pool = sniffed.Concat(SnapshotSniffedUrls()) .Where(VideoDownloader.LooksLikeMedia) .Where(u => !VideoDownloader.IsDashAudioOnly(u)) // 纯音频轨不能单独当视频下 .Distinct() .ToList(); int index; if (!string.IsNullOrEmpty(videoId)) { // 有视频 ID 时,优先找 URL 里包含该 ID 的整片地址 index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u) && u.Contains(videoId)); } else { // 没有 ID 就退回最后一个整片地址 index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u)); } if (index >= 0) { string url = pool[index]; // 看看能不能从嗅探记录里配到同组音频轨 string? pairedAudio = FindPairedAudio(url, videoId); return new VideoCandidate(url, null, RefererFor(url, pageUrl), pairedAudio); } string? text = sniffedPlaylists.Concat(ReadBlobPlaylists(grabResultJson)) .Distinct() .LastOrDefault(t => t.Contains("#EXTM3U")); return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl); } /// /// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。 /// private sealed class GrabInfo { public string Current = string.Empty; public string PageUrl = string.Empty; public string DashVideo = string.Empty; public string DashAudio = string.Empty; public string VideoId = string.Empty; public List Sniffed = new(); } private static GrabInfo ParseGrab(string grabResultJson) { GrabInfo g = new GrabInfo(); try { // ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号 string? inner = JsonSerializer.Deserialize(grabResultJson); if (!string.IsNullOrEmpty(inner)) { JsonNode? node = JsonNode.Parse(inner); g.Current = node?["current"]?.GetValue() ?? string.Empty; g.PageUrl = node?["pageUrl"]?.GetValue() ?? string.Empty; g.DashVideo = node?["dashVideo"]?.GetValue() ?? string.Empty; g.DashAudio = node?["dashAudio"]?.GetValue() ?? string.Empty; g.VideoId = node?["videoId"]?.GetValue() ?? string.Empty; if (node?["sniffed"] is JsonArray arr) { foreach (JsonNode? item in arr) { string? u = item?.GetValue(); if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!); } } } } catch { // 页面没有返回可解析结果时,只用宿主侧嗅探到的地址 } return g; } /// /// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前, /// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。 /// private (List Items, HashSet CurrentUrls) CollectCandidates(string grabResultJson) { GrabInfo g = ParseGrab(grabResultJson); List items = new List(); HashSet currentUrls = new HashSet(); void Add(string? url, string? audio, bool isCurrent) { if (string.IsNullOrEmpty(url) || !VideoDownloader.LooksLikeMedia(url) || VideoDownloader.IsSegmentUrl(url) || VideoDownloader.IsDashAudioOnly(url)) { return; } if (items.Any(i => i.Url == url)) { if (isCurrent) currentUrls.Add(url!); return; } // 只有 DASH 纯视频轨才需要配音频轨;普通整片直链自带音轨,乱配反而会把别人的声音合进去 string? pairedAudio = audio ?? (VideoDownloader.IsDashVideoOnly(url) ? FindPairedAudio(url!, isCurrent ? g.VideoId : IdFromUrl(url!)) : null); items.Add(new VideoCandidate(url, null, RefererFor(url, g.PageUrl), pairedAudio)); if (isCurrent) currentUrls.Add(url!); } // 当前播放的两条源排最前,方便和下面的嗅探条目肉眼对比 Add(g.Current, null, true); Add(g.DashVideo, string.IsNullOrEmpty(g.DashAudio) ? null : g.DashAudio, true); List pool = g.Sniffed.Concat(SnapshotSniffedUrls()) .Distinct() .ToList(); // 有视频 ID 时先把同 ID 的地址排前面,它们多半就是当前视频 if (!string.IsNullOrEmpty(g.VideoId)) { pool = pool.OrderByDescending(u => u.Contains(g.VideoId)).ToList(); } // 嗅探列表里越新的越接近当前视频,倒序追加 for (int i = pool.Count - 1; i >= 0; i--) { string url = pool[i]; Add(url, null, !string.IsNullOrEmpty(g.VideoId) && url.Contains(g.VideoId)); } return (items, currentUrls); } /// /// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。 /// private static string IdFromUrl(string url) { try { Match m = Regex.Match(new Uri(url).AbsolutePath, @"\d{10,}"); return m.Success ? m.Value : string.Empty; } catch { return string.Empty; } } /// /// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。 /// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。 /// private string? FindPairedAudio(string videoUrl, string videoId = "") { lock (sniffedUrls) { // 有视频 ID 时,优先找同 ID 的音频轨 if (!string.IsNullOrEmpty(videoId)) { for (int i = sniffedUrls.Count - 1; i >= 0; i--) { if (VideoDownloader.IsDashAudioOnly(sniffedUrls[i]) && sniffedUrls[i].Contains(videoId)) return sniffedUrls[i]; } } // 退回:从后往前找最后一条音频轨 for (int i = sniffedUrls.Count - 1; i >= 0; i--) { if (VideoDownloader.IsDashAudioOnly(sniffedUrls[i])) return sniffedUrls[i]; } } return null; } private static List ReadBlobPlaylists(string grabResultJson) { List result = new List(); try { string? inner = JsonSerializer.Deserialize(grabResultJson); if (inner != null && JsonNode.Parse(inner)?["m3u8Blobs"] is JsonArray arr) { foreach (JsonNode? item in arr) { string? text = item?.GetValue(); if (!string.IsNullOrWhiteSpace(text) && text.Contains("#EXTM3U")) result.Add(text); } } } catch { // 忽略 } return result; } /// /// 下载当前播放的这个地址:先让用户选保存位置,再流式落盘。 /// 地址只有一个,失败就直接报错误信息,不再翻别的地址重试。 /// private async Task DownloadVideoAsync(VideoCandidate candidate) { string url = candidate.Url; VideoDownloader namer = new VideoDownloader(RefererFor(url, webView21.CoreWebView2?.Source)); using SaveFileDialog dlg = new SaveFileDialog { Title = "下载视频", FileName = namer.SuggestedFileName(url, PageTitle), Filter = "视频文件|*.mp4;*.ts;*.mkv;*.flv;*.mov|所有文件|*.*", AddExtension = true }; if (dlg.ShowDialog(this) != DialogResult.OK) { SetStatus("已取消"); return; } string? failure = await TryDownloadAsync(candidate, dlg.FileName); if (failure == null) return; SetStatus($"下载失败:{failure}"); MessageBox.Show(this, failure, "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Warning); } /// /// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。 /// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。 /// private async Task TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true) { VideoDownloader downloader = await CreateDownloaderAsync(candidate); downloadCts = new CancellationTokenSource(); Progress progress = new Progress(ReportProgress); progressBar.Visible = true; progressBar.Value = 0; cancelBut.Enabled = true; SetStatus($"准备下载 {Path.GetFileName(filePath)}"); try { DownloadResult result; if (candidate.PlaylistText != null) { result = await downloader.DownloadFromPlaylistTextAsync(candidate.PlaylistText, candidate.Url, filePath, progress, downloadCts.Token); } else if (!string.IsNullOrEmpty(candidate.AudioUrl)) { result = await downloader.DownloadDashPairAsync(candidate.Url, candidate.AudioUrl, filePath, progress, downloadCts.Token); } else { result = await downloader.DownloadAsync(candidate.Url, filePath, progress, downloadCts.Token); } SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})"); if (promptOpenFolder && MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?", "下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes) { OpenFolder(result.FilePath); } return null; } catch (OperationCanceledException) { // 用户主动取消就别再换地址重试了;批量路径靠这个标志中止剩余条目 downloadWasCanceled = true; SetStatus("已取消下载"); return null; } catch (Exception ex) { DeletePartFiles(filePath); return ex.Message; } finally { downloadCts?.Dispose(); downloadCts = null; progressBar.Visible = false; cancelBut.Enabled = false; } } /// /// 用候选地址自己的 Referer 和浏览器里的 Cookie 建下载器: /// 播放器在跨域 iframe 里时,拿顶层页面地址当 Referer 会被防盗链拒掉。 /// private async Task CreateDownloaderAsync(VideoCandidate candidate) { string? referer = candidate.Referer ?? RefererFor(candidate.Url, webView21.CoreWebView2?.Source); string? cookie = await GetCookieHeaderAsync(candidate.Url); return new VideoDownloader(referer, cookie); } /// /// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程, /// 否则截图预览的后台任务会在这里永久卡死。 /// private Task GetCookieHeaderAsync(string url) => OnUiAsync(async () => { CoreWebView2? core = webView21.CoreWebView2; if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null; try { var cookies = await core.CookieManager.GetCookiesAsync(uri.ToString()); string header = string.Join("; ", cookies .Where(c => !string.IsNullOrEmpty(c.Name)) .Select(c => $"{c.Name}={c.Value}")); return header.Length == 0 ? null : header; } catch { // 拿不到 Cookie 就按匿名请求下载 return null; } }); private void ReportProgress(DownloadProgress value) { SetStatus(value.Message); if (value.Ratio != null) { progressBar.Value = (int)Math.Clamp(value.Ratio.Value * 100, 0, 100); } } private void cancelBut_Click(object sender, EventArgs e) { try { downloadCts?.Cancel(); SetStatus("正在取消…"); } catch (Exception ex) { SetStatus($"取消失败:{ex.Message}"); } } private static void DeletePartFiles(string target) { try { string part = target + ".part"; if (File.Exists(part)) File.Delete(part); } catch { // 残留的 .part 交给下次覆盖 } } /// /// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。 /// private static string UniqueFilePath(string folder, string fileName) { string path = Path.Combine(folder, fileName); if (!File.Exists(path)) return path; string name = Path.GetFileNameWithoutExtension(fileName); string ext = Path.GetExtension(fileName); for (int i = 2; ; i++) { path = Path.Combine(folder, $"{name} ({i}){ext}"); if (!File.Exists(path)) return path; } } private static void OpenFolder(string filePath) { try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch { // 打不开文件夹不影响下载结果 } } private string PageTitle { get { try { return webView21.CoreWebView2?.DocumentTitle ?? string.Empty; } catch { return string.Empty; } } } #endregion } }