This commit is contained in:
owenchen committed 2026-11-03 09:19:50 +08:00
1 parent ac0112b4a3
commit 90bb20e75f
24 files changed
+2722

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WinFormsApp3
{
internal class Class1
{
}
}/*
// 这是正确、最简、不会报错的 NER 辅助类
public class NerInput
{
public string Text { get; set; }
}
public class NerOutput
{
// 不需要 Label!不会报错!
public string[] Words { get; set; }
}
var mlContext = new MLContext();
// ============================
// 关键:只做 分词 + 特征转换
// 绝对不加分类器,就不会要 Label
// ============================
var pipeline = mlContext.Transforms.Text.TokenizeIntoWords("Words", nameof(NerInput.Text));
// 空数据也不会报错
var emptyData = mlContext.Data.LoadFromEnumerable(new List<NerInput>());
var model = pipeline.Fit(emptyData);
// 预测引擎
var engine = mlContext.Model.CreatePredictionEngine<NerInput, NerOutput>(model);
// 测试
var result = engine.Predict(new NerInput
{
Text = "我和陈明今天去上海见张伟"
});
Console.WriteLine("分词结果:");
foreach (var word in result.Words)
{
Console.WriteLine("- " + word);
}
*/
+244
View File
@@ -0,0 +1,244 @@
namespace WinFormsApp3
{
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
tableLayoutPanel1 = new TableLayoutPanel();
webView21 = new Microsoft.Web.WebView2.WinForms.WebView2();
tableLayoutPanel2 = new TableLayoutPanel();
urlTxt = new TextBox();
goBut = new Button();
displayBut = new Button();
cssBut = new Button();
addLinkBut = new Button();
linkBut = new Button();
dlBut = new Button();
statusStrip1 = new StatusStrip();
statusLbl = new ToolStripStatusLabel();
progressBar = new ToolStripProgressBar();
cancelBut = new ToolStripButton();
tableLayoutPanel1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)webView21).BeginInit();
tableLayoutPanel2.SuspendLayout();
statusStrip1.SuspendLayout();
SuspendLayout();
//
// tableLayoutPanel1
//
tableLayoutPanel1.ColumnCount = 1;
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 20F));
tableLayoutPanel1.Controls.Add(webView21, 0, 1);
tableLayoutPanel1.Controls.Add(tableLayoutPanel2, 0, 0);
tableLayoutPanel1.Controls.Add(statusStrip1, 0, 2);
tableLayoutPanel1.Dock = DockStyle.Fill;
tableLayoutPanel1.Location = new Point(0, 0);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 3;
tableLayoutPanel1.RowStyles.Add(new RowStyle());
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 50F));
tableLayoutPanel1.RowStyles.Add(new RowStyle());
tableLayoutPanel1.Size = new Size(1416, 785);
tableLayoutPanel1.TabIndex = 1;
//
// webView21
//
webView21.AllowExternalDrop = true;
webView21.DefaultBackgroundColor = Color.White;
webView21.Dock = DockStyle.Fill;
webView21.Location = new Point(3, 38);
webView21.Name = "webView21";
webView21.Size = new Size(1410, 715);
webView21.TabIndex = 0;
webView21.ZoomFactor = 1D;
//
// tableLayoutPanel2
//
tableLayoutPanel2.AutoSize = true;
tableLayoutPanel2.ColumnCount = 7;
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
tableLayoutPanel2.Controls.Add(urlTxt, 0, 0);
tableLayoutPanel2.Controls.Add(goBut, 1, 0);
tableLayoutPanel2.Controls.Add(displayBut, 5, 0);
tableLayoutPanel2.Controls.Add(linkBut, 4, 0);
tableLayoutPanel2.Controls.Add(cssBut, 3, 0);
tableLayoutPanel2.Controls.Add(addLinkBut, 2, 0);
tableLayoutPanel2.Controls.Add(dlBut, 6, 0);
tableLayoutPanel2.Dock = DockStyle.Fill;
tableLayoutPanel2.Location = new Point(3, 3);
tableLayoutPanel2.Name = "tableLayoutPanel2";
tableLayoutPanel2.RowCount = 1;
tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
tableLayoutPanel2.Size = new Size(1410, 29);
tableLayoutPanel2.TabIndex = 1;
//
// urlTxt
//
urlTxt.Dock = DockStyle.Fill;
urlTxt.Location = new Point(3, 3);
urlTxt.Name = "urlTxt";
urlTxt.Size = new Size(1134, 23);
urlTxt.TabIndex = 0;
//
// goBut
//
goBut.Image = Google_Chrome.Properties.Resources.ok_16;
goBut.Location = new Point(1143, 3);
goBut.Name = "goBut";
goBut.Size = new Size(55, 23);
goBut.TabIndex = 1;
goBut.UseVisualStyleBackColor = true;
goBut.Click += goBut_Click;
//
// displayBut
//
displayBut.Image =Google_Chrome.Properties.Resources.view_16;
displayBut.Location = new Point(1387, 3);
displayBut.Name = "displayBut";
displayBut.Size = new Size(20, 23);
displayBut.TabIndex = 1;
displayBut.UseVisualStyleBackColor = true;
displayBut.Click += displayBut_Click;
//
// cssBut
//
cssBut.Image = Google_Chrome.Properties.Resources.element;
cssBut.Location = new Point(1265, 3);
cssBut.Name = "cssBut";
cssBut.Size = new Size(55, 23);
cssBut.TabIndex = 1;
cssBut.UseVisualStyleBackColor = true;
cssBut.Click += cssBut_Click;
//
// addLinkBut
//
addLinkBut.Image = Google_Chrome.Properties.Resources.element_plus_16;
addLinkBut.Location = new Point(1204, 3);
addLinkBut.Name = "addLinkBut";
addLinkBut.Size = new Size(55, 23);
addLinkBut.TabIndex = 1;
addLinkBut.UseVisualStyleBackColor = true;
addLinkBut.Click += addLinkBut_Click;
//
// linkBut
//
linkBut.Image = Google_Chrome.Properties.Resources.page_link;
linkBut.Location = new Point(1326, 3);
linkBut.Name = "linkBut";
linkBut.Size = new Size(55, 23);
linkBut.TabIndex = 1;
linkBut.UseVisualStyleBackColor = true;
linkBut.Click += linkBut_Click;
//
// dlBut
//
dlBut.Image = Google_Chrome.Properties.Resources.save_16;
dlBut.Location = new Point(1382, 3);
dlBut.Name = "dlBut";
dlBut.Size = new Size(75, 23);
dlBut.TabIndex = 1;
dlBut.Text = "下载";
dlBut.TextImageRelation = TextImageRelation.ImageBeforeText;
dlBut.UseVisualStyleBackColor = true;
dlBut.Click += dlBut_Click;
//
// statusStrip1
//
statusStrip1.Items.AddRange(new ToolStripItem[] { statusLbl, progressBar, cancelBut });
statusStrip1.Location = new Point(0, 759);
statusStrip1.Name = "statusStrip1";
statusStrip1.Size = new Size(1410, 26);
statusStrip1.TabIndex = 2;
//
// statusLbl
//
statusLbl.Name = "statusLbl";
statusLbl.Size = new Size(1080, 21);
statusLbl.Spring = true;
statusLbl.Text = "就绪";
statusLbl.TextAlign = ContentAlignment.MiddleLeft;
//
// progressBar
//
progressBar.Name = "progressBar";
progressBar.Size = new Size(200, 18);
progressBar.Visible = false;
//
// cancelBut
//
cancelBut.DisplayStyle = ToolStripItemDisplayStyle.Text;
cancelBut.Enabled = false;
cancelBut.Name = "cancelBut";
cancelBut.Size = new Size(60, 23);
cancelBut.Text = "取消下载";
cancelBut.Click += cancelBut_Click;
//
// Form1
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(1416, 785);
Controls.Add(tableLayoutPanel1);
Name = "Form1";
Text = "在线";
FormClosing += Form1_FormClosing;
Load += Form1_Load;
tableLayoutPanel1.ResumeLayout(false);
tableLayoutPanel1.PerformLayout();
((System.ComponentModel.ISupportInitialize)webView21).EndInit();
tableLayoutPanel2.ResumeLayout(false);
tableLayoutPanel2.PerformLayout();
statusStrip1.ResumeLayout(false);
statusStrip1.PerformLayout();
ResumeLayout(false);
}
#endregion
private TableLayoutPanel tableLayoutPanel1;
private Microsoft.Web.WebView2.WinForms.WebView2 webView21;
private TableLayoutPanel tableLayoutPanel2;
private TextBox urlTxt;
private Button goBut;
private Button displayBut;
private Button cssBut;
private Button addLinkBut;
private Button linkBut;
private Button dlBut;
private StatusStrip statusStrip1;
private ToolStripStatusLabel statusLbl;
private ToolStripProgressBar progressBar;
private ToolStripButton cancelBut;
}
}
+767
View File
@@ -0,0 +1,767 @@
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
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+71
View File
@@ -0,0 +1,71 @@
namespace WinFormsApp3
{
partial class Form2
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
cssTxt = new RichTextBox();
saveBut = new Button();
SuspendLayout();
//
// cssTxt
//
cssTxt.Location = new Point(12, 12);
cssTxt.Name = "cssTxt";
cssTxt.Size = new Size(772, 366);
cssTxt.TabIndex = 0;
cssTxt.Text = "";
//
// saveBut
//
saveBut.Image =Google_Chrome.Properties.Resources.save_16;
saveBut.Location = new Point(699, 395);
saveBut.Name = "saveBut";
saveBut.Size = new Size(57, 43);
saveBut.TabIndex = 1;
saveBut.UseVisualStyleBackColor = true;
saveBut.Click += saveBut_Click;
//
// Form2
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(800, 450);
Controls.Add(saveBut);
Controls.Add(cssTxt);
Name = "Form2";
Text = "Form2";
Load += Form2_Load;
ResumeLayout(false);
}
#endregion
private RichTextBox cssTxt;
private Button saveBut;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WinFormsApp3
{
public partial class Form2 : Form
{
public string Path { get; set; }
public Form2()
{
InitializeComponent();
}
private void Form2_Load(object sender, EventArgs e)
{
if (File.Exists(Path))
{
cssTxt.Text = File.ReadAllText(Path);
}
}
private void saveBut_Click(object sender, EventArgs e)
{
File.WriteAllText(Path, cssTxt.Text);
this.Close();
}
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+47
View File
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML" Version="5.0.0" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
<PackageReference Include="Microsoft.ML.OnnxTransformer" Version="5.0.0" />
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
</ItemGroup>
<ItemGroup>
<!-- 页面注入脚本:必须拷到输出目录,下载按钮靠它们嗅探视频真实地址 -->
<None Update="Resources\media_sniffer.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\video_button.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\video_grab.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Compile Update="Form1.cs">
<SubType>Form</SubType>
</Compile>
<Compile Update="Form2.cs">
<SubType>Form</SubType>
</Compile>
</ItemGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.11.12328.437 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google Chrome", "Google Chrome.csproj", "{CF401873-0387-4EB8-85E3-9B757EF9EA54}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0AF3BB1F-186C-4D9D-8E04-1935ECCD5E4A}
EndGlobalSection
EndGlobal
+97
View File
@@ -0,0 +1,97 @@
using Microsoft.ML;
using Microsoft.ML.Data;
using Microsoft.ML.Transforms.Onnx;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using WinFormsApp3;
//https://huggingface.co/protectai/gyr66-bert-base-chinese-finetuned-ner-onnx/tree/main
//model.onnx
//vocab.txt
namespace WinFormsApp3
{
class Program
{
[STAThread] // ← 加上这个,99% 立刻解决你的错误
static int Main(string[] args)
{
// 命令行下载:WinFormsApp3.exe --download <视频地址> [--out 保存路径] [--referer 页面地址]
if (args.Length > 0 && string.Equals(args[0], "--download", StringComparison.OrdinalIgnoreCase))
{
// 下载丢给线程池线程去跑,不要在 STA 主线程上等待异步,否则 COM 线程模式会冲突
return Task.Run(() => DownloadCliAsync(args)).GetAwaiter().GetResult();
}
ApplicationConfiguration.Initialize();
// 这里不能提前 form.Show():那会让 Form.Load 在消息循环开始之前执行,
// WebView2 在没有消息泵时初始化会报 RPC_E_CHANGED_MODE,交给 Run 自己显示窗体
Application.Run(new Form1());
return 0;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int dwProcessId);
private const int AttachParentProcess = -1;
/// <summary>
/// 命令行下载入口:不创建窗体、不初始化 WebView2,直接把地址下完就退出,方便脚本化验证。
/// </summary>
private static async Task<int> DownloadCliAsync(string[] args)
{
// WinExe 默认没有控制台,Attach 到父进程的输出才能在终端里看到结果
AttachConsole(AttachParentProcess);
string url = args.Length > 1 ? args[1] : string.Empty;
if (string.IsNullOrWhiteSpace(url) || url.StartsWith("--", StringComparison.Ordinal))
{
Console.Error.WriteLine("用法: WinFormsApp3.exe --download <视频地址> [--out 保存路径] [--referer 页面地址]");
return 2;
}
string? referer = GetOption(args, "--referer");
string? outPath = GetOption(args, "--out");
if (string.IsNullOrWhiteSpace(outPath))
{
outPath = Path.Combine(Path.GetTempPath(), new VideoDownloader(referer).SuggestedFileName(url, null));
}
using CancellationTokenSource cts = new(TimeSpan.FromMinutes(15));
Progress<DownloadProgress> progress = new(message => Console.WriteLine(message.Message));
try
{
VideoDownloader downloader = new(referer);
DownloadResult result = await downloader.DownloadAsync(url, outPath!, progress, cts.Token);
Console.WriteLine($"OK file={result.FilePath} bytes={result.Bytes} converted={result.Converted}");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"FAIL {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
/// <summary>
/// 取 --out / --referer 这类"选项 值"形式的参数。
/// </summary>
private static string? GetOption(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 這段程式碼是由工具產生的。
// 執行階段版本:4.0.30319.42000
//
// 對這個檔案所做的變更可能會造成錯誤的行為,而且如果重新產生程式碼,
// 變更將會遺失。
// </auto-generated>
//------------------------------------------------------------------------------
namespace Google_Chrome.Properties {
using System;
/// <summary>
/// 用於查詢當地語系化字串等的強類型資源類別。
/// </summary>
// 這個類別是自動產生的,是利用 StronglyTypedResourceBuilder
// 類別透過 ResGen 或 Visual Studio 這類工具。
// 若要加入或移除成員,請編輯您的 .ResX 檔,然後重新執行 ResGen
// (利用 /str 選項),或重建您的 VS 專案。
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// 傳回這個類別使用的快取的 ResourceManager 執行個體。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Google_Chrome.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// 覆寫目前執行緒的 CurrentUICulture 屬性,對象是所有
/// 使用這個強類型資源類別的資源查閱。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap element {
get {
object obj = ResourceManager.GetObject("element", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap element_plus_16 {
get {
object obj = ResourceManager.GetObject("element_plus_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap ok_16 {
get {
object obj = ResourceManager.GetObject("ok_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap page_link {
get {
object obj = ResourceManager.GetObject("page_link", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap save_16 {
get {
object obj = ResourceManager.GetObject("save_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap search_16 {
get {
object obj = ResourceManager.GetObject("search_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap view_16 {
get {
object obj = ResourceManager.GetObject("view_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
}
}
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="view_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\view_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="search_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\search_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="ok_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\ok_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="element_plus_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\element_plus_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="page_link" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\page_link.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="element" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\element.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="save_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\save_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>
Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

@@ -0,0 +1,144 @@
// 资源嗅探脚本:在安装页面播放器脚本之前接管 fetch / XMLHttpRequest / createObjectURL,
// 把真正被下载的媒体地址(mp4 / m3u8 等)回报给宿主程序,用于"下载视频"按钮。
(function () {
if (window.__mediaSniffInstalled) return;
window.__mediaSniffInstalled = true;
window.__mediaUrls = [];
window.__m3u8Blobs = [];
var MEDIA_EXT =
/\.(mp4|flv|m3u8|mpd|mkv|mov|m4v|webm|f4v|3gp|mpg|mpeg|ts)(\?|#|$)/i;
var PLAIN_EXT =
/\.(jpg|jpeg|png|gif|webp|ico|svg|bmp|css|js|json|woff2?|ttf|map|txt)(\?|#|$)/i;
function isMediaUrl(u) {
if (!u) return false;
u = String(u);
if (u.indexOf("blob:") === 0 || u.indexOf("data:") === 0) return false;
if (PLAIN_EXT.test(u)) return false;
return MEDIA_EXT.test(u) || /\/(video|vod|playback|media)[\/-]/i.test(u);
}
function absUrl(u) {
try {
return new URL(String(u), location.href).href;
} catch (e) {
return String(u);
}
}
function post(msg) {
try {
if (
window.chrome &&
window.chrome.webview &&
window.chrome.webview.postMessage
) {
window.chrome.webview.postMessage(JSON.stringify(msg));
}
} catch (e) {}
}
function remember(text) {
if (!text || typeof text !== "string") return;
if (text.indexOf("#EXTM3U") < 0) return;
if (window.__m3u8Blobs.indexOf(text) >= 0) return;
window.__m3u8Blobs.push(text);
post({ type: "hlsText", text: text });
}
function report(u) {
try {
var full = absUrl(u);
if (!isMediaUrl(full)) return;
if (window.__mediaUrls.indexOf(full) >= 0) return;
window.__mediaUrls.push(full);
// base 是发出这个请求的文档地址:播放器常在跨域 iframe 里,
// 宿主必须用它当 Referer,用顶层页面地址会被防盗链判为非法来源
post({ type: "media", url: full, base: location.href });
} catch (e) {}
}
window.__reportMedia = report;
window.__rememberHlsText = remember;
// 抖音等站点会用 atob(base64) 把播放地址还原出来,这里顺手抓一份解码结果
try {
var origAtob = window.atob;
if (origAtob) {
window.atob = function (s) {
var r = origAtob.apply(this, arguments);
try {
if (r && typeof r === "string") {
if (r.indexOf("#EXTM3U") >= 0) remember(r);
else if (isMediaUrl(r)) report(r);
}
} catch (e) {}
return r;
};
}
} catch (e) {}
try {
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function (input, init) {
try {
var u =
typeof input === "string" || input instanceof URL
? input
: input && input.url;
if (u) report(u);
} catch (e) {}
return origFetch.apply(this, arguments);
};
}
} catch (e) {}
try {
var xhrOpen = XMLHttpRequest.prototype.open;
var xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, u) {
try {
if (u) report(u);
} catch (e) {}
return xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
try {
xhr.addEventListener("load", function () {
try {
if (xhr.responseType === "" || xhr.responseType === "text") {
remember(xhr.responseText);
} else if (
xhr.responseType === "blob" &&
xhr.response &&
xhr.response.text
) {
xhr.response.text().then(remember);
}
} catch (e) {}
});
} catch (e) {}
return xhrSend.apply(this, arguments);
};
} catch (e) {}
// MSE 播放时 video.src 只能是 blob:,抓不到 http 地址,所以靠上面的分片嗅探兜底;
// 这里额外记录 createObjectURL 的调用,方便识别"确实在用 MSE"。
try {
var ocu = window.URL && window.URL.createObjectURL;
if (ocu) {
window.URL.createObjectURL = function (obj) {
var u = ocu.apply(this, arguments);
try {
window.__usesMse = true;
if (obj && typeof obj.text === "function") obj.text().then(remember);
} catch (e) {}
return u;
};
}
} catch (e) {}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

@@ -0,0 +1,80 @@
// 在页面里注入一个悬浮"下载视频"按钮,贴在当前视频播放器右下角。
// 点击后通过 postMessage 通知宿主程序开始下载流程。
(function () {
var BTN_ID = "qz-video-download-btn";
function post(msg) {
try {
if (
window.chrome &&
window.chrome.webview &&
window.chrome.webview.postMessage
) {
window.chrome.webview.postMessage(JSON.stringify(msg));
}
} catch (e) {}
}
function bigVideo() {
var vids = document.querySelectorAll("video");
var best = null,
bestArea = 0;
for (var i = 0; i < vids.length; i++) {
var r = vids[i].getBoundingClientRect();
var area = r.width * r.height;
if (r.width > 120 && r.height > 60 && area > bestArea) {
best = vids[i];
bestArea = area;
}
}
return best;
}
function ensureButton() {
var btn = document.getElementById(BTN_ID);
var v = bigVideo();
if (!btn && v) {
btn = document.createElement("button");
btn.id = BTN_ID;
btn.type = "button";
btn.innerText = "下载视频";
btn.style.cssText =
"position:fixed;z-index:2147483647;padding:5px 12px;border:0;border-radius:16px;" +
"background:#ff2d55;color:#fff;font-size:13px;line-height:1.4;cursor:pointer;" +
'box-shadow:0 2px 8px rgba(0,0,0,.35);font-family:system-ui,"Microsoft YaHei",sans-serif;';
btn.addEventListener(
"click",
function (e) {
e.preventDefault();
e.stopPropagation();
post({ type: "downloadClick", pageUrl: location.href });
},
true,
);
document.documentElement.appendChild(btn);
}
if (btn) {
if (!v) {
btn.style.display = "none";
return;
}
var r = v.getBoundingClientRect();
btn.style.display = "block";
btn.style.left = Math.max(8, r.right - btn.offsetWidth - 12) + "px";
btn.style.top = Math.max(8, r.bottom - btn.offsetHeight - 12) + "px";
}
}
// 播放进度变化时(抖音翻页、切换清晰度)重新定位
document.addEventListener("timeupdate", ensureButton, true);
window.addEventListener("resize", ensureButton, true);
window.addEventListener("scroll", ensureButton, true);
document.addEventListener("mouseover", ensureButton, true);
if (!window.__downloadBtnTimer) {
window.__downloadBtnTimer = setInterval(ensureButton, 800);
}
ensureButton();
})();
@@ -0,0 +1,60 @@
// 点击"下载视频"时执行:读取当前 <video> 的播放源,并把嗅探到的候选地址一起返回给宿主。
(function () {
try {
var vids = Array.prototype.slice.call(document.querySelectorAll("video"));
var best = null;
for (var i = 0; i < vids.length; i++) {
var v = vids[i];
if (v.videoWidth > 0 || !isNaN(v.duration)) {
best = v;
break;
}
}
if (!best) best = vids[0];
var current = "";
var playable = true;
if (best) {
current = best.currentSrc || best.src || "";
if (!current) {
var s = best.querySelector && best.querySelector("source");
if (s) current = s.src || s.getAttribute("src") || "";
}
if (best.networkState === 0 || (best.msKeySystemLoaded && !best.src))
playable = false;
}
var title = document.title || "";
var h1 = document.querySelector("h1");
if (h1 && h1.innerText) title = h1.innerText;
var anchors = [];
var links = document.querySelectorAll("a[href]");
for (var j = 0; j < links.length; j++) {
var h = links[j].href;
if (
window.__reportMedia &&
/\.(mp4|flv|m3u8|mov|mkv|webm|ts)(\?|#|$)/i.test(h)
)
anchors.push(h);
}
return JSON.stringify({
current: current || "",
playable: playable,
usesMse: !!window.__usesMse || /^blob:/i.test(current),
title: title,
pageUrl: location.href,
sniffed: (window.__mediaUrls || []).slice(-60),
anchors: anchors.slice(-20),
m3u8Blobs: (window.__m3u8Blobs || []).slice(-1),
});
} catch (e) {
return JSON.stringify({
current: "",
title: document.title || "",
pageUrl: location.href,
error: String(e),
});
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+573
View File
@@ -0,0 +1,573 @@
using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
namespace WinFormsApp3
{
/// <summary>
/// 下载进度:文字说明 + 可选的百分比(0~1)。
/// </summary>
public record DownloadProgress(string Message, double? Ratio);
/// <summary>
/// 下载结果。
/// </summary>
/// <param name="FilePath">最终落盘的文件路径(HLS 转封装后可能改变扩展名)。</param>
/// <param name="Bytes">写出的字节数。</param>
/// <param name="Converted">是否由 ffmpeg 把 ts 转封装为 mp4。</param>
public record DownloadResult(string FilePath, long Bytes, bool Converted);
/// <summary>
/// 视频下载器:支持直链(mp4/flv/ts…)与 HLS(m3u8)分片合并下载。
/// 请求时带上当前页面 Referer,抖音等站点校验 Referer 后才允许访问播放地址。
/// </summary>
public class VideoDownloader
{
private const string UserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0";
private static readonly Regex CharsetRx = new(@"charset\s*=\s*""?([\w-]+)""?", RegexOptions.IgnoreCase);
private static readonly string[] MediaExtensions =
[".mp4", ".flv", ".m3u8", ".mpd", ".mkv", ".mov", ".m4v", ".webm", ".f4v", ".3gp", ".ts", ".mpg", ".mpeg"];
/// <summary>
/// 整片容器格式。这些后缀永远不能当作 HLS 分片,否则 /video/xxx.flv 这类地址会被误删。
/// </summary>
private static readonly string[] WholeVideoExtensions =
[".mp4", ".flv", ".mkv", ".mov", ".webm", ".m4v", ".f4v", ".3gp", ".avi", ".rmvb", ".mpg", ".mpeg", ".m3u8", ".mpd"];
private readonly string? _referer;
private readonly string? _cookie;
/// <param name="referer">播放器所在页面的地址,不是顶层页面地址(播放器常在跨域 iframe 里)。</param>
/// <param name="cookie">从 WebView2 配置文件里取出的 Cookie,部分站点没 Cookie 直接给空文件。</param>
public VideoDownloader(string? referer, string? cookie = null)
{
if (!string.IsNullOrWhiteSpace(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var u)
&& !string.IsNullOrEmpty(u.Host))
{
// 防盗链一般只校来源站点,保留完整路径反而更容易被判定为非法来源;
// 用 Authority 而不是 Host,带端口的站点把端口去掉后来源就对不上了
_referer = $"{u.Scheme}://{u.Authority}/";
}
if (!string.IsNullOrWhiteSpace(cookie)) _cookie = cookie;
}
public static bool IsHlsUrl(string url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"\.m3u8(\?|#|$)", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。
/// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据,
/// 否则 /video/xxx.flv 这种整片地址会被当成分片删掉。
/// </summary>
public static bool IsSegmentUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
var path = Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.AbsolutePath.ToLowerInvariant() : url.ToLowerInvariant();
var extension = Path.GetExtension(path);
// 没有扩展名的地址多为接口型直链(如抖音),当作整片
if (extension.Length == 0) return false;
if (WholeVideoExtensions.Any(e => extension == e)) return false;
return extension is ".ts" or ".m4s" or ".init" or ".m2ts" or ".aac" or ".mp3"
|| Regex.IsMatch(path, @"(seg|chunk|part|fragment|f)[-_.]?\d+");
}
public static bool LooksLikeMedia(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
if (url.StartsWith("blob:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return false;
if (u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps) return false;
var path = u.AbsolutePath.ToLowerInvariant();
if (Regex.IsMatch(path, @"\.(jpg|jpeg|png|gif|webp|ico|svg|css|js|json|woff2?|ttf|map)$")) return false;
if (MediaExtensions.Any(e => path.EndsWith(e))) return true;
return Regex.IsMatch(path, @"/(video|vod|playback|media)[/-]");
}
/// <summary>
/// 根据地址推断保存扩展名;m3u8 会合并成单个文件,有 ffmpeg 时转 mp4,否则输出 ts。
/// </summary>
public static string ExtensionFor(string url)
{
var raw = RawExtension(url);
if (raw == ".m3u8" || (raw.Length == 0 && IsHlsUrl(url))) return HasFfmpeg() ? ".mp4" : ".ts";
if (raw.Length == 0) return ".mp4";
return raw;
}
public static string RawExtension(string url)
{
try
{
return Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant();
}
catch
{
return "";
}
}
/// <summary>
/// 生成建议的文件名:优先用页面标题,其次用地址末段。
/// </summary>
public string SuggestedFileName(string url, string? title)
{
var name = CleanFileName(title);
if (string.IsNullOrWhiteSpace(name))
{
try
{
name = CleanFileName(Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath));
}
catch
{
// 忽略,使用兜底名
}
}
if (string.IsNullOrWhiteSpace(name)) name = "video";
if (name.Length > 60) name = name[..60];
return name + ExtensionFor(url);
}
private static string? CleanFileName(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var cleaned = Regex.Replace(text, @"[\\/:*?""<>|\r\n\t]", " ").Trim();
cleaned = Regex.Replace(cleaned, @"\s+", " ");
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
/// <summary>
/// 下载视频。m3u8 会自动挑选最高清晰度并顺序合并分片。
/// </summary>
public async Task<DownloadResult> DownloadAsync(string url, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var client = CreateClient();
if (IsHlsUrl(url))
{
progress?.Report(new DownloadProgress("解析播放列表…", null));
var playlist = await ResolvePlaylistAsync(client, url, ct);
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
}
return await DownloadSingleAsync(client, url, filePath, progress, ct);
}
/// <summary>
/// 站点只用 MSE 播放、抓不到 m3u8 地址时,直接拿页面里嗅探到的播放列表文本下载。
/// </summary>
public async Task<DownloadResult> DownloadFromPlaylistTextAsync(string playlistText, string baseUrl, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var client = CreateClient();
progress?.Report(new DownloadProgress("解析播放列表…", null));
var playlist = await ParsePlaylistAsync(playlistText, baseUrl, client, ct, 0);
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
}
private HttpClient CreateClient()
{
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromMinutes(15)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
if (_referer != null) client.DefaultRequestHeaders.Referrer = new Uri(_referer);
if (_cookie != null) client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", _cookie);
return client;
}
private async Task<DownloadResult> DownloadSingleAsync(HttpClient client, string url, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var resp = await GetFollowRedirectAsync(client, url, ct);
EnsureVideoResponse(resp);
var total = resp.Content.Headers.ContentLength ?? -1L;
var part = filePath + ".part";
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(filePath))!);
long done = 0;
long lastReport = 0;
await using var input = await resp.Content.ReadAsStreamAsync(ct);
try
{
var buffer = new byte[1024 * 64];
// 先读一小块验货:防盗链站点经常不报 403,而是给一段 200 的 HTML 错误页
int read = await input.ReadAsync(buffer.AsMemory(0, 4096), ct);
if (read > 0) EnsureRealVideoContent(buffer, read, resp, url);
await using var output = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, true);
while (read > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
done += read;
if (done - lastReport >= 256 * 1024)
{
lastReport = done;
progress?.Report(total > 0
? new DownloadProgress($"下载中 {FormatSize(done)} / {FormatSize(total)}", done / (double)total)
: new DownloadProgress($"下载中 {FormatSize(done)}(总大小未知,可点取消结束)", null));
}
read = await input.ReadAsync(buffer, ct);
}
}
catch
{
TryDelete(part);
throw;
}
MoveIntoPlace(part, filePath);
progress?.Report(new DownloadProgress($"完成 {FormatSize(done)}", 1));
return new DownloadResult(filePath, done, false);
}
/// <summary>
/// 把常见 HTTP 错误码翻译成能行动的原因,只说“403”用户不知道下一步该做什么。
/// </summary>
private static void EnsureVideoResponse(HttpResponseMessage resp)
{
if (resp.IsSuccessStatusCode) return;
string hint = (int)resp.StatusCode switch
{
403 or 401 => ",防盗链或需要登录:先在页面里把视频播起来再点下载,或换一条线路",
404 => ",地址已失效(这类直链通常只有特别短的有效期)",
416 => ",服务器不接受当前请求范围,重试即可",
_ => string.Empty
};
throw new HttpRequestException($"服务器返回 {(int)resp.StatusCode} {resp.StatusCode}{hint}");
}
/// <summary>
/// 真正的视频容器不会以 "&lt;" 或 "{" 开头;m3u8 / mpd 本身是文本,不参与检查。
/// </summary>
private static void EnsureRealVideoContent(byte[] head, int length, HttpResponseMessage resp, string url)
{
if (IsHlsUrl(url) || RawExtension(url) == ".mpd") return;
var type = resp.Content.Headers.ContentType?.MediaType ?? string.Empty;
if (type.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)
|| type.StartsWith("text/plain", StringComparison.OrdinalIgnoreCase)
|| type.Contains("json", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"站点返回的是 {type} 而不是视频,多半是防盗链或需要先登录/先播放");
}
int i = 0;
while (i < length && (head[i] is 0xEF or 0xBB or 0xBF or 0x20 or 0x0D or 0x0A or 0x09)) i++;
if (i < length && (head[i] == (byte)'<' || head[i] == (byte)'{'))
{
throw new NotSupportedException("站点返回的是网页内容而不是视频(防盗链或地址已过期),换一条线路重试");
}
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* 删不掉不影响报错 */ }
}
private async Task<DownloadResult> WriteHlsAsync(HttpClient client, HlsPlaylist playlist, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
if (playlist.Segments.Count == 0)
throw new InvalidOperationException("播放列表里没有解析到视频分片,可能还在加载中,请稍后重试");
if (playlist.KeyLine != null && playlist.KeyLine.Contains("METHOD=", StringComparison.OrdinalIgnoreCase)
&& !playlist.KeyLine.Contains("METHOD=NONE", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"该视频是加密 HLS({playlist.KeyLine}),需要解密 Key,暂不支持自动下载");
}
// 分片直接拼接出来的是 .ts;装了 ffmpeg 时顺手无损转封装成 mp4,播放更通用
var wantsMp4 = string.Equals(Path.GetExtension(filePath), ".mp4", StringComparison.OrdinalIgnoreCase);
var ffmpeg = wantsMp4 ? FindFfmpeg() : null;
if (wantsMp4 && ffmpeg == null)
{
// 没有 ffmpeg 就不能把 ts 流写成 .mp4 后缀,否则很多播放器认不出来
filePath = Path.ChangeExtension(filePath, ".ts");
progress?.Report(new DownloadProgress("未找到 ffmpeg,改存为 .ts(播放器可以直接播)", null));
}
var mergedPath = ffmpeg != null
? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".ts")
: filePath;
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mergedPath))!);
long total = 0;
int index = 0;
var part = mergedPath + ".part";
await using (var output = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, true))
{
foreach (var segment in playlist.Segments)
{
ct.ThrowIfCancellationRequested();
using var resp = await GetFollowRedirectAsync(client, segment, ct);
resp.EnsureSuccessStatusCode();
await using var input = await resp.Content.ReadAsStreamAsync(ct);
await input.CopyToAsync(output, ct);
total += resp.Content.Headers.ContentLength ?? 0;
index++;
if (index % 5 == 0 || index == playlist.Segments.Count)
progress?.Report(new DownloadProgress($"下载分片 {index}/{playlist.Segments.Count},已下载 {FormatSize(total)}", index / (double)playlist.Segments.Count));
}
}
if (ffmpeg == null)
{
MoveIntoPlace(part, mergedPath);
progress?.Report(new DownloadProgress($"完成 {FormatSize(total)}", 1));
return new DownloadResult(mergedPath, new FileInfo(mergedPath).Length, false);
}
progress?.Report(new DownloadProgress("用 ffmpeg 转封装为 mp4…", null));
MoveIntoPlace(part, mergedPath);
try
{
var ok = await ConvertToMp4Async(ffmpeg, mergedPath, filePath, ct);
if (ok)
{
try { File.Delete(mergedPath); } catch { /* 临时文件删不掉不影响结果 */ }
var size = new FileInfo(filePath).Length;
progress?.Report(new DownloadProgress($"完成 {FormatSize(size)}", 1));
return new DownloadResult(filePath, size, true);
}
}
catch
{
// 转封装失败时保留 ts 文件,避免用户什么都拿不到
}
var tsSize = new FileInfo(mergedPath).Length;
progress?.Report(new DownloadProgress($"完成(未能转成 mp4,已保存 ts){FormatSize(tsSize)}", 1));
return new DownloadResult(mergedPath, tsSize, false);
}
private record HlsPlaylist(List<string> Segments, string? KeyLine);
/// <summary>
/// 解析 m3u8:主播放列表按带宽挑最高清晰度,最后返回绝对地址的分片列表。
/// </summary>
private async Task<HlsPlaylist> ResolvePlaylistAsync(HttpClient client, string url, CancellationToken ct, int depth = 0)
{
if (depth > 3) throw new InvalidOperationException("播放列表嵌套层级过深");
using var resp = await GetFollowRedirectAsync(client, url, ct);
resp.EnsureSuccessStatusCode();
var text = await ReadTextAsync(resp, ct);
return await ParsePlaylistAsync(text, url, client, ct, depth);
}
private async Task<HlsPlaylist> ParsePlaylistAsync(string text, string url, HttpClient client, CancellationToken ct, int depth)
{
if (!text.Contains("#EXTM3U"))
throw new InvalidOperationException("返回的内容不是 m3u8 播放列表,请改用列表里的 mp4 直链下载");
var lines = text.Split('\n').Select(l => l.Trim('\r', ' ', '\t')).ToList();
// 主播放列表:#EXT-X-STREAM-INF 后面紧跟子列表地址
var variants = new List<(string Uri, int Bandwidth)>();
for (int i = 0; i < lines.Count; i++)
{
if (!lines[i].StartsWith("#EXT-X-STREAM-INF", StringComparison.OrdinalIgnoreCase)) continue;
var bandwidth = 0;
var m = Regex.Match(lines[i], @"BANDWIDTH=(\d+)", RegexOptions.IgnoreCase);
if (m.Success) bandwidth = int.Parse(m.Groups[1].Value);
for (int j = i + 1; j < lines.Count; j++)
{
if (lines[j].Length == 0 || lines[j].StartsWith("#")) continue;
variants.Add((lines[j], bandwidth));
break;
}
}
if (variants.Count > 0)
{
var best = variants[^1];
foreach (var v in variants)
if (v.Bandwidth > best.Bandwidth) best = v;
ct.ThrowIfCancellationRequested();
return await ResolvePlaylistAsync(client, Resolve(url, best.Uri), ct, depth + 1);
}
var segments = lines
.Where(l => l.Length > 0 && !l.StartsWith("#"))
.Select(l => Resolve(url, l))
.ToList();
var keyLine = lines.FirstOrDefault(l => l.StartsWith("#EXT-X-KEY", StringComparison.OrdinalIgnoreCase));
return new HlsPlaylist(segments, keyLine);
}
private static string Resolve(string baseUrl, string relative)
{
if (Path.IsPathRooted(relative) || relative.StartsWith("//", StringComparison.Ordinal))
{
try
{
var u = new Uri(baseUrl);
return $"{u.Scheme}:{relative}";
}
catch
{
return relative;
}
}
if (Uri.TryCreate(relative, UriKind.Absolute, out _)) return relative;
try
{
return new Uri(new Uri(baseUrl), relative).ToString();
}
catch
{
return relative;
}
}
private static async Task<HttpResponseMessage> GetFollowRedirectAsync(HttpClient client, string url, CancellationToken ct)
{
var current = url;
HttpResponseMessage? previous = null;
for (int i = 0; i < 6; i++)
{
var resp = await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, ct);
if (resp.IsSuccessStatusCode) return resp;
var status = (int)resp.StatusCode;
var location = resp.Headers.Location;
if (status < 300 || status > 399 || location == null) return resp;
previous?.Dispose();
previous = resp;
current = location.IsAbsoluteUri ? location.ToString() : new Uri(new Uri(current), location).ToString();
}
return previous ?? await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, ct);
}
/// <summary>
/// m3u8 常用 GBK 编码,直接按 UTF8 读会让中文路径的分片地址出错。
/// </summary>
private static async Task<string> ReadTextAsync(HttpResponseMessage resp, CancellationToken ct)
{
using var ms = new MemoryStream();
await resp.Content.CopyToAsync(ms, ct);
var encoding = Encoding.UTF8;
var match = CharsetRx.Match(resp.Content.Headers.ContentType?.ToString() ?? "");
if (match.Success)
{
try { encoding = Encoding.GetEncoding(match.Groups[1].Value); }
catch { /* 未知编码退回 UTF8 */ }
}
var text = encoding.GetString(ms.ToArray());
// 去掉可能存在的 BOM
if (text.Length > 0 && text[0] == '\uFEFF') text = text[1..];
return text;
}
private static async Task<bool> ConvertToMp4Async(string ffmpeg, string tsFile, string mp4File, CancellationToken ct)
{
var args = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{tsFile}\" -c copy -bsf:a aac_adtstoasc -movflags +faststart \"{mp4File}\"";
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var proc = Process.Start(psi);
if (proc == null) return false;
using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
await proc.WaitForExitAsync(ct);
}
return proc.ExitCode == 0 && File.Exists(mp4File) && new FileInfo(mp4File).Length > 0;
}
/// <summary>
/// 查找 ffmpeg:先看程序目录,再看 PATH。找到就能把 HLS 转成 mp4,找不到则只合并 ts。
/// </summary>
public static string? FindFfmpeg()
{
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
if (File.Exists(local)) return local;
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
try
{
var candidate = Path.Combine(dir.Trim().Trim('"'), "ffmpeg.exe");
if (File.Exists(candidate)) return candidate;
}
catch
{
// PATH 里存在非法字符时跳过该项
}
}
return null;
}
public static bool HasFfmpeg() => FindFfmpeg() != null;
private static void MoveIntoPlace(string partFile, string targetFile)
{
if (File.Exists(targetFile)) File.Delete(targetFile);
File.Move(partFile, targetFile);
}
public static string FormatSize(long bytes)
{
if (bytes <= 0) return "0 B";
if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):0.00} GB";
if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):0.0} MB";
if (bytes >= 1L << 10) return $"{bytes / (double)(1L << 10):0.0} KB";
return $"{bytes} B";
}
}
}