diff --git a/uniapp/Google Chrome/Class1.cs b/uniapp/Google Chrome/Class1.cs new file mode 100644 index 0000000..46c776a --- /dev/null +++ b/uniapp/Google Chrome/Class1.cs @@ -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()); + var model = pipeline.Fit(emptyData); + + // 预测引擎 + var engine = mlContext.Model.CreatePredictionEngine(model); + + // 测试 + var result = engine.Predict(new NerInput + { + Text = "我和陈明今天去上海见张伟" + }); + + Console.WriteLine("分词结果:"); + foreach (var word in result.Words) + { + Console.WriteLine("- " + word); + } + */ \ No newline at end of file diff --git a/uniapp/Google Chrome/Form1.Designer.cs b/uniapp/Google Chrome/Form1.Designer.cs new file mode 100644 index 0000000..c6866bf --- /dev/null +++ b/uniapp/Google Chrome/Form1.Designer.cs @@ -0,0 +1,260 @@ +namespace WinFormsApp3 +{ + partial class Form1 + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + 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(); + listBut = 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 = 8; + 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.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(listBut, 6, 0); + tableLayoutPanel2.Controls.Add(dlBut, 7, 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; + // + // listBut + // + listBut.Image = Google_Chrome.Properties.Resources.search_16; + listBut.Location = new Point(1382, 3); + listBut.Name = "listBut"; + listBut.Size = new Size(55, 23); + listBut.TabIndex = 1; + listBut.Text = "列表"; + listBut.TextImageRelation = TextImageRelation.ImageBeforeText; + listBut.UseVisualStyleBackColor = true; + listBut.Click += listBut_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 Button listBut; + private StatusStrip statusStrip1; + private ToolStripStatusLabel statusLbl; + private ToolStripProgressBar progressBar; + private ToolStripButton cancelBut; + } +} diff --git a/uniapp/Google Chrome/Form1.cs b/uniapp/Google Chrome/Form1.cs new file mode 100644 index 0000000..ea481c5 --- /dev/null +++ b/uniapp/Google Chrome/Form1.cs @@ -0,0 +1,1274 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Microsoft.Web.WebView2.Core; + +namespace WinFormsApp3 +{ + /// + /// 当前正在播放的视频地址。 + /// + /// 可直接 HTTP 访问的视频地址;PlaylistText 不为空时作为相对地址基准。 + /// 只在内存里拿到 m3u8 文本时使用(抖音这类 MSE 播放)。 + /// 发出这个请求的文档地址;跨域 iframe 播放器必须用它,不然会被防盗链拒掉。 + /// DASH 分离音轨地址;不为空时需要和 Url 一起下载后用 ffmpeg 合并。 + public record VideoCandidate(string Url, string? PlaylistText = null, string? Referer = null, string? AudioUrl = null); + + public partial class Form1 : Form + { + private const string SnifferScript = @"Resources\media_sniffer.js"; + private const string ButtonScript = @"Resources\video_button.js"; + private const string GrabScript = @"Resources\video_grab.js"; + + private double currentZoomFactor = 1.3; + private readonly List sniffedUrls = new(); + private readonly List sniffedPlaylists = new(); + + /// 视频地址 → 发出请求的文档地址,用作下载时的 Referer。 + private readonly Dictionary urlReferers = new(); + private readonly Dictionary injectedScripts = new(); + private CancellationTokenSource? downloadCts; + private bool hooksAttached; + + /// 批量下载时用于识别“用户按了取消”,直接中止后续条目。 + private bool downloadWasCanceled; + + public Form1() + { + InitializeComponent(); + + string css = @" + .pt-top-nav { display: none !important; } + .pt-head-bg { display: none !important; } + .pt-navbg { display: none !important; } + .erwm { display: none !important; } + .pt-right { display: none !important; } + .pt-copyright { display: none !important; } + .pt-crumb { display: none !important; } + .txtinfo { display: none !important; } + .mys-wrapper { display: none !important; } + .tools { display: none !important; } + .headbox { display: none !important; } + .setbox { display: none !important; } + .foot { display: none !important; } + "; + + if (File.Exists("css.txt")) + { + css = File.ReadAllText("css.txt"); + } + + webView21.NavigationCompleted += async (sender, e) => + { + if (e.IsSuccess) + { + await InjectCssAsync(css); + } + }; + + urlTxt.Text = "http://www.luoqiuzww.cc"; + } + + private async Task InjectCssAsync(string css) + { + if (File.Exists("css.txt")) + { + css = File.ReadAllText("css.txt"); + } + + string script = $@" + var style = document.createElement('style'); + style.type = 'text/css'; + style.innerHTML = `{css}`; + document.head.appendChild(style); + "; + + await webView21.CoreWebView2.ExecuteScriptAsync(script); + } + + private void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) + { + if (e.IsSuccess) + { + urlTxt.Text = ((Microsoft.Web.WebView2.Core.CoreWebView2)sender).Source; + // 小延迟确保DOM完全加载 + Task.Delay(100).ContinueWith(_ => + { + this.Invoke((MethodInvoker)delegate + { + if (Math.Abs(webView21.ZoomFactor - currentZoomFactor) > 0.01f) + { + webView21.ZoomFactor = currentZoomFactor; + } + }); + }); + } + } + + /// + /// 顶层页面导航完成后重置嗅探结果:只保留当前页面里播过的地址, + /// 免得长时间浏览后下载地址越攒越多。 + /// + private void OnMainNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e) + { + if (!e.IsSuccess) + { + return; + } + + lock (sniffedUrls) sniffedUrls.Clear(); + lock (urlReferers) urlReferers.Clear(); + sniffedPlaylists.Clear(); + } + + private async Task EnsureCoreAsync() + { + if (webView21.CoreWebView2 != null) + { + return; + } + + try + { + // 初始化 CoreWebView2 + await webView21.EnsureCoreWebView2Async(null); + + if (!hooksAttached && webView21.CoreWebView2 is { } core) + { + hooksAttached = true; + + core.FrameNavigationCompleted += OnNavigationCompleted; + core.NavigationCompleted += OnNavigationCompleted; + // 换了页面就把上一页嗅探到的地址清空,下载时只会拿到当前这页在播的视频 + core.NavigationCompleted += OnMainNavigationCompleted; + core.DOMContentLoaded += (sender, e) => webView21.ZoomFactor = currentZoomFactor; + webView21.ZoomFactorChanged += (sender, e) => currentZoomFactor = webView21.ZoomFactor; + + // 页面里的嗅探脚本靠这两个事件把视频地址回传 + core.WebMessageReceived += OnWebMessageReceived; + // 站点自带的"下载"按钮(如抖音)也统一走另存为,避免存到看不见的默认目录 + core.DownloadStarting += OnDownloadStarting; + // 新窗口就地导航,保证嗅探脚本仍然在跑 + core.NewWindowRequested += OnNewWindowRequested; + } + + await InjectScriptsAsync(); + } + catch (Exception ex) + { + MessageBox.Show($"初始化 WebView2 失败: {ex.Message}"); + } + } + + /// + /// 在文档创建阶段注入嗅探与悬浮按钮脚本,保证早于播放器脚本运行(抖音视频是翻页加载的,尤其需要早注入)。 + /// + private async Task InjectScriptsAsync() + { + var core = webView21.CoreWebView2; + if (core == null) + { + return; + } + + foreach (var file in new[] { SnifferScript, ButtonScript }) + { + if (injectedScripts.ContainsKey(file)) + { + continue; + } + + string code = ReadScript(file); + if (string.IsNullOrEmpty(code)) + { + continue; + } + + string id = await core.AddScriptToExecuteOnDocumentCreatedAsync(code); + if (!string.IsNullOrEmpty(id)) + { + injectedScripts[file] = id; + } + } + } + + private async void InitializeWebView() + { + await EnsureCoreAsync(); + + if (webView21.CoreWebView2 == null) + { + return; + } + + try + { + // 加载网页 + webView21.CoreWebView2.Navigate(urlTxt.Text); + } + catch (Exception ex) + { + MessageBox.Show($"加载页面失败: {ex.Message}"); + } + } + + private void Form1_Load(object sender, EventArgs e) + { + if (File.Exists("currentLink.txt")) + { + urlTxt.Text = File.ReadAllText("currentLink.txt"); + } + + // 打开就直接加载页面,不用先点一次“转到”才能看视频 + InitializeWebView(); + } + + private void Form1_FormClosing(object sender, FormClosingEventArgs e) + { + File.WriteAllText("currentLink.txt", urlTxt.Text); + } + + private void cssBut_Click(object sender, EventArgs e) + { + ShowDialog("css.txt"); + } + + private void addLinkBut_Click(object sender, EventArgs e) + { + File.AppendAllText("link.txt", "\r\n" + urlTxt.Text); + } + + private void linkBut_Click(object sender, EventArgs e) + { + ShowDialog("link.txt"); + } + + private void ShowDialog(string path) + { + Form2 form2 = new Form2(); + form2.Path = path; + form2.ShowDialog(); + } + + private void displayBut_Click(object sender, EventArgs e) + { + urlTxt.Visible = !urlTxt.Visible; + } + + private void goBut_Click(object sender, EventArgs e) + { + InitializeWebView(); + } + + #region 视频下载 + + private static string ReadScript(string file) + { + try + { + string path = Path.IsPathRooted(file) ? file : Path.Combine(Application.StartupPath, file); + return File.Exists(path) ? File.ReadAllText(path) : string.Empty; + } + catch + { + return string.Empty; + } + } + + private void SetStatus(string text) + { + if (IsHandleCreated && InvokeRequired) + { + BeginInvoke(new Action(() => statusLbl.Text = text)); + } + else + { + statusLbl.Text = text; + } + } + + /// + /// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口, + /// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。 + /// + private async Task OnUiAsync(Func> action) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + PreviewLog.Write($"OnUiAsync 排队 {action.Method.Name}"); + try + { + BeginInvoke(new Action(async () => + { + try + { + T r = await action(); + PreviewLog.Write($"OnUiAsync 完成 {action.Method.Name}"); + tcs.SetResult(r); + } + catch (Exception ex) { tcs.SetException(ex); } + })); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + return await tcs.Task; + } + + /// + /// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度, + /// 只留最近若干个,供“当前播放地址取不到时”兜底用。 + /// + private void AddSniffedUrl(string url, string? referer) + { + lock (sniffedUrls) + { + if (!sniffedUrls.Contains(url)) + { + sniffedUrls.Add(url); + } + + while (sniffedUrls.Count > 50) sniffedUrls.RemoveAt(0); + } + + if (!string.IsNullOrWhiteSpace(referer)) + { + lock (urlReferers) urlReferers[url] = referer; + } + } + + private string? RefererFor(string url, string? fallback) + { + lock (urlReferers) + { + if (urlReferers.TryGetValue(url, out var referer)) return referer; + } + + return string.IsNullOrWhiteSpace(fallback) ? null : fallback; + } + + private List SnapshotSniffedUrls() + { + lock (sniffedUrls) return sniffedUrls.ToList(); + } + + /// + /// 接收页面嗅探脚本回传的视频地址与播放列表文本。 + /// + private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e) + { + try + { + string json = e.TryGetWebMessageAsString(); + JsonNode? node = JsonNode.Parse(json); + string? type = node?["type"]?.GetValue(); + + switch (type) + { + case "media": + { + string? url = node?["url"]?.GetValue(); + string? referer = node?["base"]?.GetValue(); + if (!string.IsNullOrEmpty(url) && VideoDownloader.LooksLikeMedia(url)) + { + AddSniffedUrl(url, referer); + if (VideoDownloader.IsHlsUrl(url)) + { + SetStatus("已嗅探到 m3u8,可点击下载"); + } + } + break; + } + case "hlsText": + { + string? text = node?["text"]?.GetValue(); + if (!string.IsNullOrEmpty(text) && text.Contains("#EXTM3U") && !sniffedPlaylists.Contains(text)) + { + sniffedPlaylists.Add(text); + // 只留最近几份播放列表,够用又不会越积越多 + while (sniffedPlaylists.Count > 10) sniffedPlaylists.RemoveAt(0); + SetStatus("已抓到播放列表,可点击下载"); + } + break; + } + case "downloadClick": + // 播放器右下角的悬浮按钮 + _ = DownloadCurrentVideoAsync(); + break; + } + } + catch + { + // 非本程序约定的消息直接忽略 + } + } + + /// + /// 站点自己发起的下载(例如抖音自带的下载入口)改为用户选择保存位置。 + /// + private void OnDownloadStarting(object? sender, CoreWebView2DownloadStartingEventArgs e) + { + string suggested = Path.GetFileName(e.DownloadOperation.ResultFilePath); + if (string.IsNullOrWhiteSpace(suggested)) + { + suggested = "video.mp4"; + } + + using SaveFileDialog dlg = new SaveFileDialog + { + Title = "保存视频", + FileName = suggested, + Filter = "视频文件|*.mp4;*.ts;*.mkv;*.flv;*.mov|所有文件|*.*" + }; + + if (dlg.ShowDialog(this) != DialogResult.OK) + { + e.Cancel = true; + SetStatus("已取消下载"); + return; + } + + string target = dlg.FileName; + e.Handled = true; + e.ResultFilePath = target; + SetStatus($"正在下载 {Path.GetFileName(target)}"); + + CoreWebView2DownloadOperation operation = e.DownloadOperation; + operation.BytesReceivedChanged += (s, ev) => + { + ulong? total = operation.TotalBytesToReceive; + string totalText = total > 0 ? $" / {VideoDownloader.FormatSize((long)total!.Value)}" : string.Empty; + SetStatus($"下载中 {VideoDownloader.FormatSize(operation.BytesReceived)}{totalText}"); + }; + operation.StateChanged += (s, ev) => + { + switch (operation.State) + { + case CoreWebView2DownloadState.Completed: + SetStatus($"已保存 {target}"); + break; + case CoreWebView2DownloadState.Interrupted: + SetStatus($"下载中断:{operation.InterruptReason}"); + break; + } + }; + } + + private void OnNewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs e) + { + // 抖音等站点的 window.open 在这里就地导航,避免新窗口没有嗅探脚本 + e.Handled = true; + webView21.CoreWebView2?.Navigate(e.Uri); + } + + private async void dlBut_Click(object sender, EventArgs e) + { + await DownloadCurrentVideoAsync(); + } + + private async void listBut_Click(object sender, EventArgs e) + { + await ShowVideoListAsync(); + } + + /// + /// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒), + /// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。 + /// + private async Task ShowVideoListAsync() + { + if (webView21.CoreWebView2 == null) + { + SetStatus("页面还没加载好,请稍候"); + return; + } + + if (downloadCts != null) + { + SetStatus("已有下载在进行中,可先点“取消下载”"); + return; + } + + listBut.Enabled = false; + try + { + SetStatus("正在识别本页视频…"); + string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript()); + (List items, HashSet currentUrls) = CollectCandidates(raw); + + if (items.Count == 0) + { + SetStatus("没识别到视频地址"); + MessageBox.Show(this, + "本页没有识别到可下载的视频地址。\n\n" + + "常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n" + + "2. 站点用了加密流(DRM),无法直接下载;\r\n" + + "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。", + "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // 关键:列表窗口是模态对话框,实测对话框存在期间 WebView2 的异步回调 + // (ExecuteScriptAsync / GetCookiesAsync)不会送达,要等窗口关闭才一次性返回。 + // 所以所有依赖 WebView2 的数据必须在 ShowDialog 之前预取完毕, + // 后台预览任务只做 ffmpeg 抽帧,不再碰 WebView2。 + SetStatus("正在取登录态与播放器画面…"); + Dictionary cookies = new Dictionary(); + foreach (VideoCandidate item in items) + { + if (!cookies.ContainsKey(item.Url)) + { + cookies[item.Url] = await GetCookieHeaderAsync(item.Url); + } + } + List canvasFrames = await CaptureCurrentFramesAsync(CancellationToken.None); + Image? currentStrip = VideoListForm.ComposeFrames(canvasFrames); + PreviewLog.Write($"预取完成 cookies={cookies.Count} canvas帧={canvasFrames.Count} strip={(currentStrip != null ? "ok" : "null")}"); + + SetStatus($"识别到 {items.Count} 个视频,正在生成截图…"); + Func> factory = + (c, ct) => BuildPreviewAsync(c, currentUrls.Contains(c.Url), currentStrip, cookies, ct); + using VideoListForm form = new VideoListForm(items, currentUrls, factory); + if (form.ShowDialog(this) != DialogResult.OK || form.SelectedCandidates.Count == 0) + { + SetStatus("已取消"); + return; + } + + using FolderBrowserDialog folderDlg = new FolderBrowserDialog + { + Description = $"选择保存文件夹(将下载 {form.SelectedCandidates.Count} 个视频)" + }; + if (folderDlg.ShowDialog(this) != DialogResult.OK) + { + SetStatus("已取消"); + return; + } + + await DownloadCandidatesBatchAsync(form.SelectedCandidates, folderDlg.SelectedPath); + } + catch (OperationCanceledException) + { + SetStatus("已取消"); + } + catch (Exception ex) + { + SetStatus($"识别失败:{ex.Message}"); + MessageBox.Show(this, ex.Message, "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + finally + { + listBut.Enabled = true; + } + } + + /// + /// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条 + /// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。 + /// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。 + /// + private async Task<(Image? Image, string? Error)> BuildPreviewAsync( + VideoCandidate candidate, bool isCurrent, Image? currentStrip, + IReadOnlyDictionary cookies, CancellationToken ct) + { + string shortUrl = candidate.Url.Length > 70 ? candidate.Url[..70] : candidate.Url; + PreviewLog.Write($"预览开始 isCurrent={isCurrent} url={shortUrl}"); + + // 只在内存里的播放列表文本没有可 seek 的地址,不抽帧 + if (candidate.PlaylistText != null) + { + return (null, "内存播放列表,无法抽帧预览"); + } + + if (isCurrent && currentStrip != null) + { + PreviewLog.Write("预览成功:播放器画面预取帧"); + return (currentStrip, null); + } + + try + { + string? referer = candidate.Referer ?? RefererFor(candidate.Url, null); + cookies.TryGetValue(candidate.Url, out string? cookie); + VideoDownloader downloader = new VideoDownloader(referer, cookie); + PreviewLog.Write("ffmpeg 抽帧开始"); + VideoDownloader.FrameExtraction result = await downloader.ExtractFramesAsync(candidate.Url, ct); + Image? img = VideoListForm.ComposeFrames(result.Frames); + PreviewLog.Write($"ffmpeg 抽帧结束 frames={result.Frames.Count} error={result.Error ?? "-"}"); + return img != null ? (img, null) : (null, result.Error ?? "没抽到帧"); + } + catch (OperationCanceledException) + { + PreviewLog.Write("预览被取消"); + throw; + } + catch (Exception ex) + { + PreviewLog.Write($"预览异常 {ex.GetType().Name}: {ex.Message}"); + return (null, ex.Message); + } + } + + /// + /// 从正在播放的 + private async Task> CaptureCurrentFramesAsync(CancellationToken ct) + { + List frames = new List(); + try + { + if (webView21.CoreWebView2 == null) return frames; + + // 后台线程不能直接碰 ExecuteScriptAsync,封送回 UI 线程; + // 整条脚本再加 20 秒硬超时,页面播放器抽风时不能把预览任务永久挂住 + using var overall = CancellationTokenSource.CreateLinkedTokenSource(ct); + overall.CancelAfter(TimeSpan.FromSeconds(20)); + string raw; + try + { + raw = await OnUiAsync(() => webView21.CoreWebView2!.ExecuteScriptAsync(CurrentFramesScript)) + .WaitAsync(overall.Token); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) + { + PreviewLog.Write($"播放器取帧脚本超时/取消:{ex.GetType().Name}"); + return frames; + } + ct.ThrowIfCancellationRequested(); + PreviewLog.Write($"播放器取帧脚本返回 {raw.Length} 字符"); + string? inner = JsonSerializer.Deserialize(raw); + if (string.IsNullOrEmpty(inner)) return frames; + + if (JsonNode.Parse(inner)?["frames"] is JsonArray arr) + { + foreach (JsonNode? item in arr) + { + string? dataUrl = item?.GetValue(); + int comma = dataUrl?.IndexOf(',') ?? -1; + if (dataUrl == null || comma < 0) continue; + try { frames.Add(Convert.FromBase64String(dataUrl[(comma + 1)..])); } + catch { /* 坏帧跳过 */ } + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // 播放器截图失败就退回 ffmpeg 远程抽帧 + } + return frames; + } + + private const string CurrentFramesScript = @" +(async function(){ + function findVideo(){ + var vids=Array.prototype.slice.call(document.querySelectorAll('video')); + for(var i=0;i0) return vids[i]; } + for(var j=0;j0) return vids[j]; } + return vids[0]||null; + } + var v=findVideo(); + if(!v) return JSON.stringify({frames:[]}); + var times=[2,7,12], frames=[], wasPaused=v.paused, t0=v.currentTime; + try{ + v.pause(); + for(var k=0;kv.duration-0.2) t=v.duration>1?v.duration-0.5:0; + var ok=true; + try{ + await new Promise(function(res,rej){ + var done=false; + var to=setTimeout(function(){ if(!done){done=true;rej('t');} },3000); + v.onseeked=function(){ if(!done){done=true;clearTimeout(to);res();} }; + try{ v.currentTime=t; }catch(e){ if(!done){done=true;clearTimeout(to);rej('s');} } + }); + }catch(e){ ok=false; } + if(!ok) break; + try{ + var c=document.createElement('canvas'); + var w=v.videoWidth||480, h=v.videoHeight||270; + var sc=Math.min(1,480/w); + c.width=Math.round(w*sc); c.height=Math.round(h*sc); + c.getContext('2d').drawImage(v,0,0,c.width,c.height); + frames.push(c.toDataURL('image/jpeg',0.85)); + }catch(e){ frames=[]; break; } + } + } finally { + // 注意:这里绝不能 await v.play()——抖音播放器会干预 play 的 Promise, + // 一旦 pending 不结束整段脚本永不返回,截图任务会永久挂死 + try{ + v.currentTime=t0; + if(!wasPaused){ try{ v.play().catch(function(){}); }catch(e){} } + }catch(e){} + } + return JSON.stringify({frames:frames}); +})()"; + + /// + /// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并; + /// 某条失败时询问是否继续,用户点取消则中止剩余条目。 + /// + private async Task DownloadCandidatesBatchAsync(List items, string folder) + { + int succeeded = 0; + List failures = new List(); + VideoDownloader namer = new VideoDownloader(RefererFor(items[0].Url, webView21.CoreWebView2?.Source)); + + for (int i = 0; i < items.Count; i++) + { + VideoCandidate item = items[i]; + string fileName = namer.SuggestedFileName(item.Url, PageTitle); + string filePath = UniqueFilePath(folder, fileName); + + SetStatus($"批量下载 {i + 1}/{items.Count}:{fileName}"); + downloadWasCanceled = false; + string? failure = await TryDownloadAsync(item, filePath, promptOpenFolder: false); + + if (downloadWasCanceled) break; // 用户取消整个批次 + + if (failure == null) + { + succeeded++; + } + else + { + failures.Add($"{fileName}:{failure}"); + if (i < items.Count - 1 && MessageBox.Show(this, + $"第 {i + 1} 个下载失败:\n{failure}\n\n跳过它继续下载剩下的吗?", + "批量下载", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) + { + break; + } + } + } + + string summary = $"成功 {succeeded} 个,失败 {failures.Count} 个(共 {items.Count})。"; + SetStatus($"批量下载完成:{summary}"); + if (failures.Count > 0) + { + summary += "\n\n" + string.Join("\n", failures.Take(8)) + (failures.Count > 8 ? "\n…" : string.Empty); + } + MessageBox.Show(this, summary, "批量下载", MessageBoxButtons.OK, + failures.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning); + + if (succeeded > 0) OpenFolder(folder); + } + + /// + /// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。 + /// + private async Task DownloadCurrentVideoAsync() + { + if (webView21.CoreWebView2 == null) + { + SetStatus("页面还没加载好,请稍候"); + return; + } + + if (downloadCts != null) + { + SetStatus("已有下载在进行中,可先点“取消下载”"); + return; + } + + dlBut.Enabled = false; + try + { + SetStatus("正在识别当前视频…"); + string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript()); + VideoCandidate? candidate = CollectCurrentCandidate(raw); + + if (candidate == null) + { + SetStatus("没识别到视频地址"); + MessageBox.Show(this, + "没有抓到当前正在播放的视频地址。\n\n" + + "常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n" + + "2. 站点用了加密流(DRM),无法直接下载;\r\n" + + "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。", + "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + await DownloadVideoAsync(candidate); + } + catch (OperationCanceledException) + { + SetStatus("已取消"); + } + catch (Exception ex) + { + SetStatus($"识别失败:{ex.Message}"); + MessageBox.Show(this, ex.Message, "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + finally + { + dlBut.Enabled = true; + } + } + + /// + /// 抓取脚本兜底版本:资源文件丢失时仍能取到当前 video 标签的播放地址。 + /// + private static string BuildGrabScript() + { + string code = ReadScript(GrabScript); + if (!string.IsNullOrEmpty(code)) + { + return code; + } + + return "(function(){var v=document.querySelector('video');" + + "return JSON.stringify({current:v?(v.currentSrc||v.src):'',title:document.title,pageUrl:location.href," + + "sniffed:(window.__mediaUrls||[]).slice(-60),m3u8Blobs:(window.__m3u8Blobs||[]).slice(-1)});})()"; + } + + /// + /// 只取当前正在播放的那一个视频地址,按四层递降: + /// 1. video 标签自己的播放源(最准确,就是正在播的这个); + /// 2. DASH 分离轨(抖音这类 blob+MSE):视频轨 + 音频轨配对,下载后合并; + /// 3. 播放器在 iframe 里取不到源时,退回用本页最近嗅探到的那一个整片地址(m3u8 或直链,分片不算); + /// 4. 只在内存里拿到播放列表文本时,用页面地址做分片相对地址的基准。 + /// + private VideoCandidate? CollectCurrentCandidate(string grabResultJson) + { + GrabInfo g = ParseGrab(grabResultJson); + string pageUrl = g.PageUrl; + List sniffed = g.Sniffed; + string dashVideo = g.DashVideo; + string dashAudio = g.DashAudio; + string videoId = g.VideoId; + string current = g.Current; + + // 第一层:video 标签自己的播放源 + if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current)) + { + return new VideoCandidate(current, null, pageUrl); + } + + // 第二层:DASH 分离轨配对(抖音)—— JS 端已按 videoId 过滤 + if (!string.IsNullOrEmpty(dashVideo) && VideoDownloader.LooksLikeMedia(dashVideo)) + { + return new VideoCandidate(dashVideo, null, RefererFor(dashVideo, pageUrl), + string.IsNullOrEmpty(dashAudio) ? null : dashAudio); + } + + // 第三层:从嗅探结果里找当前视频的地址 + List pool = sniffed.Concat(SnapshotSniffedUrls()) + .Where(VideoDownloader.LooksLikeMedia) + .Where(u => !VideoDownloader.IsDashAudioOnly(u)) // 纯音频轨不能单独当视频下 + .Distinct() + .ToList(); + + int index; + if (!string.IsNullOrEmpty(videoId)) + { + // 有视频 ID 时,优先找 URL 里包含该 ID 的整片地址 + index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u) && u.Contains(videoId)); + } + else + { + // 没有 ID 就退回最后一个整片地址 + index = pool.FindLastIndex(u => !VideoDownloader.IsSegmentUrl(u)); + } + + if (index >= 0) + { + string url = pool[index]; + // 看看能不能从嗅探记录里配到同组音频轨 + string? pairedAudio = FindPairedAudio(url, videoId); + return new VideoCandidate(url, null, RefererFor(url, pageUrl), pairedAudio); + } + + string? text = sniffedPlaylists.Concat(ReadBlobPlaylists(grabResultJson)) + .Distinct() + .LastOrDefault(t => t.Contains("#EXTM3U")); + + return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl); + } + + /// + /// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。 + /// + private sealed class GrabInfo + { + public string Current = string.Empty; + public string PageUrl = string.Empty; + public string DashVideo = string.Empty; + public string DashAudio = string.Empty; + public string VideoId = string.Empty; + public List Sniffed = new(); + } + + private static GrabInfo ParseGrab(string grabResultJson) + { + GrabInfo g = new GrabInfo(); + try + { + // ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号 + string? inner = JsonSerializer.Deserialize(grabResultJson); + if (!string.IsNullOrEmpty(inner)) + { + JsonNode? node = JsonNode.Parse(inner); + g.Current = node?["current"]?.GetValue() ?? string.Empty; + g.PageUrl = node?["pageUrl"]?.GetValue() ?? string.Empty; + g.DashVideo = node?["dashVideo"]?.GetValue() ?? string.Empty; + g.DashAudio = node?["dashAudio"]?.GetValue() ?? string.Empty; + g.VideoId = node?["videoId"]?.GetValue() ?? string.Empty; + + if (node?["sniffed"] is JsonArray arr) + { + foreach (JsonNode? item in arr) + { + string? u = item?.GetValue(); + if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!); + } + } + } + } + catch + { + // 页面没有返回可解析结果时,只用宿主侧嗅探到的地址 + } + return g; + } + + /// + /// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前, + /// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。 + /// + private (List Items, HashSet CurrentUrls) CollectCandidates(string grabResultJson) + { + GrabInfo g = ParseGrab(grabResultJson); + List items = new List(); + HashSet currentUrls = new HashSet(); + + void Add(string? url, string? audio, bool isCurrent) + { + if (string.IsNullOrEmpty(url) + || !VideoDownloader.LooksLikeMedia(url) + || VideoDownloader.IsSegmentUrl(url) + || VideoDownloader.IsDashAudioOnly(url)) + { + return; + } + if (items.Any(i => i.Url == url)) + { + if (isCurrent) currentUrls.Add(url!); + return; + } + + // 只有 DASH 纯视频轨才需要配音频轨;普通整片直链自带音轨,乱配反而会把别人的声音合进去 + string? pairedAudio = audio ?? (VideoDownloader.IsDashVideoOnly(url) + ? FindPairedAudio(url!, isCurrent ? g.VideoId : IdFromUrl(url!)) + : null); + items.Add(new VideoCandidate(url, null, RefererFor(url, g.PageUrl), pairedAudio)); + if (isCurrent) currentUrls.Add(url!); + } + + // 当前播放的两条源排最前,方便和下面的嗅探条目肉眼对比 + Add(g.Current, null, true); + Add(g.DashVideo, string.IsNullOrEmpty(g.DashAudio) ? null : g.DashAudio, true); + + List pool = g.Sniffed.Concat(SnapshotSniffedUrls()) + .Distinct() + .ToList(); + + // 有视频 ID 时先把同 ID 的地址排前面,它们多半就是当前视频 + if (!string.IsNullOrEmpty(g.VideoId)) + { + pool = pool.OrderByDescending(u => u.Contains(g.VideoId)).ToList(); + } + + // 嗅探列表里越新的越接近当前视频,倒序追加 + for (int i = pool.Count - 1; i >= 0; i--) + { + string url = pool[i]; + Add(url, null, !string.IsNullOrEmpty(g.VideoId) && url.Contains(g.VideoId)); + } + + return (items, currentUrls); + } + + /// + /// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。 + /// + private static string IdFromUrl(string url) + { + try + { + Match m = Regex.Match(new Uri(url).AbsolutePath, @"\d{10,}"); + return m.Success ? m.Value : string.Empty; + } + catch + { + return string.Empty; + } + } + + /// + /// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。 + /// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。 + /// + private string? FindPairedAudio(string videoUrl, string videoId = "") + { + lock (sniffedUrls) + { + // 有视频 ID 时,优先找同 ID 的音频轨 + if (!string.IsNullOrEmpty(videoId)) + { + for (int i = sniffedUrls.Count - 1; i >= 0; i--) + { + if (VideoDownloader.IsDashAudioOnly(sniffedUrls[i]) && + sniffedUrls[i].Contains(videoId)) + return sniffedUrls[i]; + } + } + // 退回:从后往前找最后一条音频轨 + for (int i = sniffedUrls.Count - 1; i >= 0; i--) + { + if (VideoDownloader.IsDashAudioOnly(sniffedUrls[i])) + return sniffedUrls[i]; + } + } + return null; + } + + private static List ReadBlobPlaylists(string grabResultJson) + { + List result = new List(); + try + { + string? inner = JsonSerializer.Deserialize(grabResultJson); + if (inner != null && JsonNode.Parse(inner)?["m3u8Blobs"] is JsonArray arr) + { + foreach (JsonNode? item in arr) + { + string? text = item?.GetValue(); + if (!string.IsNullOrWhiteSpace(text) && text.Contains("#EXTM3U")) result.Add(text); + } + } + } + catch + { + // 忽略 + } + return result; + } + + /// + /// 下载当前播放的这个地址:先让用户选保存位置,再流式落盘。 + /// 地址只有一个,失败就直接报错误信息,不再翻别的地址重试。 + /// + private async Task DownloadVideoAsync(VideoCandidate candidate) + { + string url = candidate.Url; + VideoDownloader namer = new VideoDownloader(RefererFor(url, webView21.CoreWebView2?.Source)); + + using SaveFileDialog dlg = new SaveFileDialog + { + Title = "下载视频", + FileName = namer.SuggestedFileName(url, PageTitle), + Filter = "视频文件|*.mp4;*.ts;*.mkv;*.flv;*.mov|所有文件|*.*", + AddExtension = true + }; + + if (dlg.ShowDialog(this) != DialogResult.OK) + { + SetStatus("已取消"); + return; + } + + string? failure = await TryDownloadAsync(candidate, dlg.FileName); + if (failure == null) return; + + SetStatus($"下载失败:{failure}"); + MessageBox.Show(this, failure, "下载视频", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + /// + /// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。 + /// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。 + /// + private async Task TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true) + { + VideoDownloader downloader = await CreateDownloaderAsync(candidate); + + downloadCts = new CancellationTokenSource(); + Progress progress = new Progress(ReportProgress); + progressBar.Visible = true; + progressBar.Value = 0; + cancelBut.Enabled = true; + SetStatus($"准备下载 {Path.GetFileName(filePath)}"); + + try + { + DownloadResult result; + if (candidate.PlaylistText != null) + { + result = await downloader.DownloadFromPlaylistTextAsync(candidate.PlaylistText, candidate.Url, filePath, progress, downloadCts.Token); + } + else if (!string.IsNullOrEmpty(candidate.AudioUrl)) + { + result = await downloader.DownloadDashPairAsync(candidate.Url, candidate.AudioUrl, filePath, progress, downloadCts.Token); + } + else + { + result = await downloader.DownloadAsync(candidate.Url, filePath, progress, downloadCts.Token); + } + + SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})"); + + if (promptOpenFolder && MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?", + "下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes) + { + OpenFolder(result.FilePath); + } + + return null; + } + catch (OperationCanceledException) + { + // 用户主动取消就别再换地址重试了;批量路径靠这个标志中止剩余条目 + downloadWasCanceled = true; + SetStatus("已取消下载"); + return null; + } + catch (Exception ex) + { + DeletePartFiles(filePath); + return ex.Message; + } + finally + { + downloadCts?.Dispose(); + downloadCts = null; + progressBar.Visible = false; + cancelBut.Enabled = false; + } + } + + /// + /// 用候选地址自己的 Referer 和浏览器里的 Cookie 建下载器: + /// 播放器在跨域 iframe 里时,拿顶层页面地址当 Referer 会被防盗链拒掉。 + /// + private async Task CreateDownloaderAsync(VideoCandidate candidate) + { + string? referer = candidate.Referer ?? RefererFor(candidate.Url, webView21.CoreWebView2?.Source); + string? cookie = await GetCookieHeaderAsync(candidate.Url); + return new VideoDownloader(referer, cookie); + } + + /// + /// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程, + /// 否则截图预览的后台任务会在这里永久卡死。 + /// + private Task GetCookieHeaderAsync(string url) => OnUiAsync(async () => + { + CoreWebView2? core = webView21.CoreWebView2; + if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null; + + try + { + var cookies = await core.CookieManager.GetCookiesAsync(uri.ToString()); + string header = string.Join("; ", cookies + .Where(c => !string.IsNullOrEmpty(c.Name)) + .Select(c => $"{c.Name}={c.Value}")); + return header.Length == 0 ? null : header; + } + catch + { + // 拿不到 Cookie 就按匿名请求下载 + return null; + } + }); + + private void ReportProgress(DownloadProgress value) + { + SetStatus(value.Message); + + if (value.Ratio != null) + { + progressBar.Value = (int)Math.Clamp(value.Ratio.Value * 100, 0, 100); + } + } + + private void cancelBut_Click(object sender, EventArgs e) + { + try + { + downloadCts?.Cancel(); + SetStatus("正在取消…"); + } + catch (Exception ex) + { + SetStatus($"取消失败:{ex.Message}"); + } + } + + private static void DeletePartFiles(string target) + { + try + { + string part = target + ".part"; + if (File.Exists(part)) File.Delete(part); + } + catch + { + // 残留的 .part 交给下次覆盖 + } + } + + /// + /// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。 + /// + private static string UniqueFilePath(string folder, string fileName) + { + string path = Path.Combine(folder, fileName); + if (!File.Exists(path)) return path; + + string name = Path.GetFileNameWithoutExtension(fileName); + string ext = Path.GetExtension(fileName); + for (int i = 2; ; i++) + { + path = Path.Combine(folder, $"{name} ({i}){ext}"); + if (!File.Exists(path)) return path; + } + } + + private static void OpenFolder(string filePath) + { + try + { + System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); + } + catch + { + // 打不开文件夹不影响下载结果 + } + } + + private string PageTitle + { + get + { + try + { + return webView21.CoreWebView2?.DocumentTitle ?? string.Empty; + } + catch + { + return string.Empty; + } + } + } + + #endregion + } +} diff --git a/uniapp/Google Chrome/Form1.resx b/uniapp/Google Chrome/Form1.resx new file mode 100644 index 0000000..8b2ff64 --- /dev/null +++ b/uniapp/Google Chrome/Form1.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/uniapp/Google Chrome/Form2.Designer.cs b/uniapp/Google Chrome/Form2.Designer.cs new file mode 100644 index 0000000..79a7228 --- /dev/null +++ b/uniapp/Google Chrome/Form2.Designer.cs @@ -0,0 +1,71 @@ +namespace WinFormsApp3 +{ + partial class Form2 + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + 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; + } +} \ No newline at end of file diff --git a/uniapp/Google Chrome/Form2.cs b/uniapp/Google Chrome/Form2.cs new file mode 100644 index 0000000..dd5e2f7 --- /dev/null +++ b/uniapp/Google Chrome/Form2.cs @@ -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(); + } + } +} diff --git a/uniapp/Google Chrome/Form2.resx b/uniapp/Google Chrome/Form2.resx new file mode 100644 index 0000000..8b2ff64 --- /dev/null +++ b/uniapp/Google Chrome/Form2.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/uniapp/Google Chrome/Google Chrome.csproj b/uniapp/Google Chrome/Google Chrome.csproj new file mode 100644 index 0000000..5830b8d --- /dev/null +++ b/uniapp/Google Chrome/Google Chrome.csproj @@ -0,0 +1,56 @@ + + + + WinExe + net8.0-windows + enable + true + enable + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + diff --git a/uniapp/Google Chrome/Google Chrome.csproj.user b/uniapp/Google Chrome/Google Chrome.csproj.user new file mode 100644 index 0000000..5d5d15b --- /dev/null +++ b/uniapp/Google Chrome/Google Chrome.csproj.user @@ -0,0 +1,11 @@ + + + + + Form + + + Form + + + \ No newline at end of file diff --git a/uniapp/Google Chrome/Google Chrome.sln b/uniapp/Google Chrome/Google Chrome.sln new file mode 100644 index 0000000..4a26a5b --- /dev/null +++ b/uniapp/Google Chrome/Google Chrome.sln @@ -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 diff --git a/uniapp/Google Chrome/PreviewLog.cs b/uniapp/Google Chrome/PreviewLog.cs new file mode 100644 index 0000000..ce71df7 --- /dev/null +++ b/uniapp/Google Chrome/PreviewLog.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; + +namespace WinFormsApp3 +{ + /// + /// 截图预览诊断日志:exe 目录下 preview_debug.log。 + /// 挂起类问题光看界面定位不到,必须把每一步(含线程号)落到磁盘上。 + /// + public static class PreviewLog + { + private static readonly object Gate = new(); + private static readonly string FilePath = Path.Combine(AppContext.BaseDirectory, "preview_debug.log"); + + public static void Write(string message) + { + string line = $"{DateTime.Now:HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId:D2}] {message}"; + try + { + lock (Gate) File.AppendAllText(FilePath, line + Environment.NewLine); + } + catch + { + // 日志写失败不影响功能 + } + } + } +} diff --git a/uniapp/Google Chrome/Program.cs b/uniapp/Google Chrome/Program.cs new file mode 100644 index 0000000..b2a90ba --- /dev/null +++ b/uniapp/Google Chrome/Program.cs @@ -0,0 +1,109 @@ +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) + { + // 全局未处理异常兜底:写日志而不是直接闪退,放到最前面 + AppDomain.CurrentDomain.UnhandledException += (s, e) => + { + try { File.WriteAllText("crash.log", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {(e.IsTerminating ? "FATAL" : "NON-FATAL")}\n{e.ExceptionObject}"); } + catch { } + }; + + // 命令行下载: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(); + try + { + Application.Run(new Form1()); + } + catch (Exception ex) + { + File.WriteAllText("crash.log", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Application.Run\n{ex}"); + throw; + } + return 0; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int dwProcessId); + + private const int AttachParentProcess = -1; + + /// + /// 命令行下载入口:不创建窗体、不初始化 WebView2,直接把地址下完就退出,方便脚本化验证。 + /// + private static async Task 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 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; + } + } + + /// + /// 取 --out / --referer 这类"选项 值"形式的参数。 + /// + 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; + } + } +} \ No newline at end of file diff --git a/uniapp/Google Chrome/Properties/Resources.Designer.cs b/uniapp/Google Chrome/Properties/Resources.Designer.cs new file mode 100644 index 0000000..21eff3b --- /dev/null +++ b/uniapp/Google Chrome/Properties/Resources.Designer.cs @@ -0,0 +1,133 @@ +//------------------------------------------------------------------------------ +// +// 這段程式碼是由工具產生的。 +// 執行階段版本:4.0.30319.42000 +// +// 對這個檔案所做的變更可能會造成錯誤的行為,而且如果重新產生程式碼, +// 變更將會遺失。 +// +//------------------------------------------------------------------------------ + +namespace Google_Chrome.Properties { + using System; + + + /// + /// 用於查詢當地語系化字串等的強類型資源類別。 + /// + // 這個類別是自動產生的,是利用 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() { + } + + /// + /// 傳回這個類別使用的快取的 ResourceManager 執行個體。 + /// + [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; + } + } + + /// + /// 覆寫目前執行緒的 CurrentUICulture 屬性,對象是所有 + /// 使用這個強類型資源類別的資源查閱。 + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap element { + get { + object obj = ResourceManager.GetObject("element", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap element_plus_16 { + get { + object obj = ResourceManager.GetObject("element_plus_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap ok_16 { + get { + object obj = ResourceManager.GetObject("ok_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap page_link { + get { + object obj = ResourceManager.GetObject("page_link", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap save_16 { + get { + object obj = ResourceManager.GetObject("save_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap search_16 { + get { + object obj = ResourceManager.GetObject("search_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。 + /// + internal static System.Drawing.Bitmap view_16 { + get { + object obj = ResourceManager.GetObject("view_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + } +} diff --git a/uniapp/Google Chrome/Properties/Resources.resx b/uniapp/Google Chrome/Properties/Resources.resx new file mode 100644 index 0000000..05e1c8a --- /dev/null +++ b/uniapp/Google Chrome/Properties/Resources.resx @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\view_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\search_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\ok_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\element_plus_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\page_link.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\element.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\save_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + \ No newline at end of file diff --git a/uniapp/Google Chrome/Resources/element.png b/uniapp/Google Chrome/Resources/element.png new file mode 100644 index 0000000..9747dec Binary files /dev/null and b/uniapp/Google Chrome/Resources/element.png differ diff --git a/uniapp/Google Chrome/Resources/element_plus_16.png b/uniapp/Google Chrome/Resources/element_plus_16.png new file mode 100644 index 0000000..8233437 Binary files /dev/null and b/uniapp/Google Chrome/Resources/element_plus_16.png differ diff --git a/uniapp/Google Chrome/Resources/media_sniffer.js b/uniapp/Google Chrome/Resources/media_sniffer.js new file mode 100644 index 0000000..5568563 --- /dev/null +++ b/uniapp/Google Chrome/Resources/media_sniffer.js @@ -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) {} +})(); diff --git a/uniapp/Google Chrome/Resources/ok_16.png b/uniapp/Google Chrome/Resources/ok_16.png new file mode 100644 index 0000000..47dc49d Binary files /dev/null and b/uniapp/Google Chrome/Resources/ok_16.png differ diff --git a/uniapp/Google Chrome/Resources/page_link.png b/uniapp/Google Chrome/Resources/page_link.png new file mode 100644 index 0000000..312eab0 Binary files /dev/null and b/uniapp/Google Chrome/Resources/page_link.png differ diff --git a/uniapp/Google Chrome/Resources/save_16.png b/uniapp/Google Chrome/Resources/save_16.png new file mode 100644 index 0000000..f9d9eab Binary files /dev/null and b/uniapp/Google Chrome/Resources/save_16.png differ diff --git a/uniapp/Google Chrome/Resources/search_16.png b/uniapp/Google Chrome/Resources/search_16.png new file mode 100644 index 0000000..1a77c32 Binary files /dev/null and b/uniapp/Google Chrome/Resources/search_16.png differ diff --git a/uniapp/Google Chrome/Resources/video_button.js b/uniapp/Google Chrome/Resources/video_button.js new file mode 100644 index 0000000..0547b55 --- /dev/null +++ b/uniapp/Google Chrome/Resources/video_button.js @@ -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(); +})(); diff --git a/uniapp/Google Chrome/Resources/video_grab.js b/uniapp/Google Chrome/Resources/video_grab.js new file mode 100644 index 0000000..a9746ee --- /dev/null +++ b/uniapp/Google Chrome/Resources/video_grab.js @@ -0,0 +1,132 @@ +// 点击"下载视频"时执行:优先从页面内嵌数据 RENDER_DATA 精确提取当前视频地址, +// 其次才用嗅探列表兜底。 +(function () { + try { + var pageUrl = location.href; + + // 从 URL 提取视频 ID + var videoId = ""; + var m = pageUrl.match(/[?&]modal_id=(\d+)/); + if (m) videoId = m[1]; + if (!videoId) { + var m0 = pageUrl.match(/[?&]aweme_id=(\d+)/); + if (m0) videoId = m0[1]; + } + if (!videoId) { + var m2 = pageUrl.match(/\/(?:video|note)\/(\d+)/); + if (m2) videoId = m2[1]; + } + + // === 方案一:从 RENDER_DATA 里精确找当前视频的播放地址 === + var renderVideoUrl = ""; + var renderAudioUrl = ""; + try { + var rd = document.getElementById("RENDER_DATA"); + if (rd && rd.textContent) { + var decoded = decodeURIComponent(rd.textContent); + // 在 JSON 里找 aweme_id 等于 videoId 的节点,提取其 video.play_addr 或 play_addr 列表 + // 用正则粗匹配,避免完整 JSON 解析的复杂性 + var idx = decoded.indexOf(videoId); + while (idx >= 0) { + // 在这个 ID 附近找 playApi / play_addr / url_list + var chunk = decoded.substring(Math.max(0, idx - 200), idx + 5000); + // 找 DASH 格式的视频轨和音频轨 + var vMatches = chunk.match(/https?:\/\/[^"\\]+media-video[^"\\]*/gi); + var aMatches = chunk.match(/https?:\/\/[^"\\]+media-audio[^"\\]*/gi); + if (vMatches && vMatches.length) { + renderVideoUrl = vMatches[vMatches.length - 1]; + if (aMatches && aMatches.length) { + renderAudioUrl = aMatches[aMatches.length - 1]; + } + break; + } + // 找普通 mp4 直链 + var mp4Matches = chunk.match(/https?:\/\/[^"\\]*\.mp4[^"\\]*/gi); + if (mp4Matches && mp4Matches.length) { + renderVideoUrl = mp4Matches[mp4Matches.length - 1]; + break; + } + idx = decoded.indexOf(videoId, idx + 1); + } + } + } catch (e) { /* RENDER_DATA 解析失败就走嗅探兜底 */ } + + // === 方案二:选正在播放的