using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Web.WebView2.Core;
namespace WinFormsApp3
{
///
/// 当前正在播放的视频地址。
///
/// 可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。
/// 只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。
/// 发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。
public record VideoCandidate(string Url, string? PlaylistText = null, string? Referer = 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;
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;
}
}
///
/// 嗅探到的地址可能来自不同 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 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. 抖音这类 blob+MSE 播放、或播放器在 iframe 里取不到源时,
/// 退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算);
/// 3. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。
///
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
{
string current = string.Empty;
string pageUrl = string.Empty;
List sniffed = new List();
try
{
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
string? inner = JsonSerializer.Deserialize(grabResultJson);
if (!string.IsNullOrEmpty(inner))
{
JsonNode? node = JsonNode.Parse(inner);
current = node?["current"]?.GetValue() ?? string.Empty;
pageUrl = node?["pageUrl"]?.GetValue() ?? string.Empty;
if (node?["sniffed"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? u = item?.GetValue();
if (!string.IsNullOrEmpty(u)) sniffed.Add(u);
}
}
}
}
catch
{
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
}
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
{
return new VideoCandidate(current, null, pageUrl);
}
// 嗅探结果里最后一个整片地址最接近“正在播的这个”,前面翻页留下的旧地址和单个分片都不取
List pool = sniffed.Concat(SnapshotSniffedUrls())
.Where(VideoDownloader.LooksLikeMedia)
.Distinct()
.ToList();
int index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u));
if (index >= 0)
{
string url = pool[index];
return new VideoCandidate(url, null, RefererFor(url, pageUrl));
}
string? text = sniffedPlaylists.Concat(ReadBlobPlaylists(grabResultJson))
.Distinct()
.LastOrDefault(t => t.Contains("#EXTM3U"));
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
}
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 表示成功或用户主动取消,否则返回错误信息给上层提示。
///
private async Task TryDownloadAsync(VideoCandidate candidate, string filePath)
{
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 = candidate.PlaylistText != null
? await downloader.DownloadFromPlaylistTextAsync(candidate.PlaylistText, candidate.Url, filePath, progress, downloadCts.Token)
: await downloader.DownloadAsync(candidate.Url, filePath, progress, downloadCts.Token);
SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})");
if (MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?",
"下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes)
{
OpenFolder(result.FilePath);
}
return null;
}
catch (OperationCanceledException)
{
// 用户主动取消就别再换地址重试了
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);
}
private async Task GetCookieHeaderAsync(string url)
{
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 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
}
}