Files
Docker/uniapp/Google Chrome/Form1.cs
T
2026-11-06 16:14:51 +08:00

1275 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Web.WebView2.Core;
namespace WinFormsApp3
{
/// <summary>
/// 当前正在播放的视频地址。
/// </summary>
/// <param name="Url">可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。</param>
/// <param name="PlaylistText">只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。</param>
/// <param name="Referer">发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。</param>
/// <param name="AudioUrl">DASH 分离音轨地址;不为空时需要和 Url 一起下载后用 ffmpeg 合并。</param>
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<string> sniffedUrls = new();
private readonly List<string> sniffedPlaylists = new();
/// <summary>视频地址 → 发出请求的文档地址,用作下载时的 Referer。</summary>
private readonly Dictionary<string, string> urlReferers = new();
private readonly Dictionary<string, string> injectedScripts = new();
private CancellationTokenSource? downloadCts;
private bool hooksAttached;
/// <summary>批量下载时用于识别“用户按了取消”,直接中止后续条目。</summary>
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;
}
});
});
}
}
/// <summary>
/// 顶层页面导航完成后重置嗅探结果:只保留当前页面里播过的地址,
/// 免得长时间浏览后下载地址越攒越多。
/// </summary>
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}");
}
}
/// <summary>
/// 在文档创建阶段注入嗅探与悬浮按钮脚本,保证早于播放器脚本运行(抖音视频是翻页加载的,尤其需要早注入)。
/// </summary>
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;
}
}
/// <summary>
/// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口,
/// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。
/// </summary>
private async Task<T> OnUiAsync<T>(Func<Task<T>> action)
{
var tcs = new TaskCompletionSource<T>(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;
}
/// <summary>
/// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度,
/// 只留最近若干个,供“当前播放地址取不到时”兜底用。
/// </summary>
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<string> SnapshotSniffedUrls()
{
lock (sniffedUrls) return sniffedUrls.ToList();
}
/// <summary>
/// 接收页面嗅探脚本回传的视频地址与播放列表文本。
/// </summary>
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
string json = e.TryGetWebMessageAsString();
JsonNode? node = JsonNode.Parse(json);
string? type = node?["type"]?.GetValue<string>();
switch (type)
{
case "media":
{
string? url = node?["url"]?.GetValue<string>();
string? referer = node?["base"]?.GetValue<string>();
if (!string.IsNullOrEmpty(url) && VideoDownloader.LooksLikeMedia(url))
{
AddSniffedUrl(url, referer);
if (VideoDownloader.IsHlsUrl(url))
{
SetStatus("已嗅探到 m3u8,可点击下载");
}
}
break;
}
case "hlsText":
{
string? text = node?["text"]?.GetValue<string>();
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
{
// 非本程序约定的消息直接忽略
}
}
/// <summary>
/// 站点自己发起的下载(例如抖音自带的下载入口)改为用户选择保存位置。
/// </summary>
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();
}
/// <summary>
/// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒),
/// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。
/// </summary>
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<VideoCandidate> items, HashSet<string> 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<string, string?> cookies = new Dictionary<string, string?>();
foreach (VideoCandidate item in items)
{
if (!cookies.ContainsKey(item.Url))
{
cookies[item.Url] = await GetCookieHeaderAsync(item.Url);
}
}
List<byte[]> 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<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> 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;
}
}
/// <summary>
/// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条
/// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。
/// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。
/// </summary>
private async Task<(Image? Image, string? Error)> BuildPreviewAsync(
VideoCandidate candidate, bool isCurrent, Image? currentStrip,
IReadOnlyDictionary<string, string?> 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);
}
}
/// <summary>
/// 从正在播放的 <video> 元素里截 3 帧(2/7/12 秒,对应“每 5 秒 3 张”):
/// 临时暂停→逐帧 seek+canvas 绘制→恢复原进度与播放状态;单次 seek 限 3 秒,
/// 遇到 tainted canvas(跨域污染)等异常就放弃,退回 ffmpeg 远程抽帧。
/// </summary>
private async Task<List<byte[]>> CaptureCurrentFramesAsync(CancellationToken ct)
{
List<byte[]> frames = new List<byte[]>();
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<string>(raw);
if (string.IsNullOrEmpty(inner)) return frames;
if (JsonNode.Parse(inner)?["frames"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? dataUrl = item?.GetValue<string>();
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;i<vids.length;i++){ if(!vids[i].paused && vids[i].currentTime>0) return vids[i]; }
for(var j=0;j<vids.length;j++){ if(vids[j].videoWidth>0) 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;k<times.length;k++){
var t=times[k];
if(isFinite(v.duration) && t>v.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});
})()";
/// <summary>
/// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并;
/// 某条失败时询问是否继续,用户点取消则中止剩余条目。
/// </summary>
private async Task DownloadCandidatesBatchAsync(List<VideoCandidate> items, string folder)
{
int succeeded = 0;
List<string> failures = new List<string>();
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);
}
/// <summary>
/// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。
/// </summary>
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;
}
}
/// <summary>
/// 抓取脚本兜底版本:资源文件丢失时仍能取到当前 video 标签的播放地址。
/// </summary>
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)});})()";
}
/// <summary>
/// 只取当前正在播放的那一个视频地址,按四层递降:
/// 1. video 标签自己的播放源(最准确,就是正在播的这个);
/// 2. DASH 分离轨(抖音这类 blob+MSE):视频轨 + 音频轨配对,下载后合并;
/// 3. 播放器在 iframe 里取不到源时,退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算);
/// 4. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。
/// </summary>
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
{
GrabInfo g = ParseGrab(grabResultJson);
string pageUrl = g.PageUrl;
List<string> 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<string> 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);
}
/// <summary>
/// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。
/// </summary>
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<string> Sniffed = new();
}
private static GrabInfo ParseGrab(string grabResultJson)
{
GrabInfo g = new GrabInfo();
try
{
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
if (!string.IsNullOrEmpty(inner))
{
JsonNode? node = JsonNode.Parse(inner);
g.Current = node?["current"]?.GetValue<string>() ?? string.Empty;
g.PageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
g.DashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
g.DashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
g.VideoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
if (node?["sniffed"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? u = item?.GetValue<string>();
if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!);
}
}
}
}
catch
{
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
}
return g;
}
/// <summary>
/// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前,
/// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。
/// </summary>
private (List<VideoCandidate> Items, HashSet<string> CurrentUrls) CollectCandidates(string grabResultJson)
{
GrabInfo g = ParseGrab(grabResultJson);
List<VideoCandidate> items = new List<VideoCandidate>();
HashSet<string> currentUrls = new HashSet<string>();
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<string> 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);
}
/// <summary>
/// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。
/// </summary>
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;
}
}
/// <summary>
/// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。
/// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。
/// </summary>
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<string> ReadBlobPlaylists(string grabResultJson)
{
List<string> result = new List<string>();
try
{
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
if (inner != null && JsonNode.Parse(inner)?["m3u8Blobs"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? text = item?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(text) && text.Contains("#EXTM3U")) result.Add(text);
}
}
}
catch
{
// 忽略
}
return result;
}
/// <summary>
/// 下载当前播放的这个地址:先让用户选保存位置,再流式落盘。
/// 地址只有一个,失败就直接报错误信息,不再翻别的地址重试。
/// </summary>
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);
}
/// <summary>
/// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。
/// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。
/// </summary>
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true)
{
VideoDownloader downloader = await CreateDownloaderAsync(candidate);
downloadCts = new CancellationTokenSource();
Progress<DownloadProgress> progress = new Progress<DownloadProgress>(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;
}
}
/// <summary>
/// 用候选地址自己的 Referer 和浏览器里的 Cookie 建下载器:
/// 播放器在跨域 iframe 里时,拿顶层页面地址当 Referer 会被防盗链拒掉。
/// </summary>
private async Task<VideoDownloader> CreateDownloaderAsync(VideoCandidate candidate)
{
string? referer = candidate.Referer ?? RefererFor(candidate.Url, webView21.CoreWebView2?.Source);
string? cookie = await GetCookieHeaderAsync(candidate.Url);
return new VideoDownloader(referer, cookie);
}
/// <summary>
/// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程,
/// 否则截图预览的后台任务会在这里永久卡死。
/// </summary>
private Task<string?> 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 交给下次覆盖
}
}
/// <summary>
/// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。
/// </summary>
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
}
}