Files
Docker/uniapp/Google Chrome/Form1.cs
T
2026-11-03 09:19:50 +08:00

768 lines
28 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 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>
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<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;
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>
/// 嗅探到的地址可能来自不同 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();
}
/// <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. 抖音这类 blob+MSE 播放、或播放器在 iframe 里取不到源时,
/// 退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算);
/// 3. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。
/// </summary>
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
{
string current = string.Empty;
string pageUrl = string.Empty;
List<string> sniffed = new List<string>();
try
{
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
if (!string.IsNullOrEmpty(inner))
{
JsonNode? node = JsonNode.Parse(inner);
current = node?["current"]?.GetValue<string>() ?? string.Empty;
pageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
if (node?["sniffed"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? u = item?.GetValue<string>();
if (!string.IsNullOrEmpty(u)) sniffed.Add(u);
}
}
}
}
catch
{
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
}
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
{
return new VideoCandidate(current, null, pageUrl);
}
// 嗅探结果里最后一个整片地址最接近“正在播的这个”,前面翻页留下的旧地址和单个分片都不取
List<string> 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<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 表示成功或用户主动取消,否则返回错误信息给上层提示。
/// </summary>
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath)
{
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 = 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;
}
}
/// <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);
}
private async Task<string?> 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
}
}