This commit is contained in:
owenchen committed 2026-11-06 16:14:51 +08:00
1 parent bcf82d0f45
commit 8260c417e2
7 files changed
+1148 -44

No files matched your search

+480 -39
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Web.WebView2.Core;
namespace WinFormsApp3
@@ -29,6 +30,9 @@ namespace WinFormsApp3
private CancellationTokenSource? downloadCts;
private bool hooksAttached;
/// <summary>批量下载时用于识别“用户按了取消”,直接中止后续条目。</summary>
private bool downloadWasCanceled;
public Form1()
{
InitializeComponent();
@@ -283,6 +287,34 @@ namespace WinFormsApp3
}
}
/// <summary>
/// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口,
/// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。
/// </summary>
private async Task<T> OnUiAsync<T>(Func<Task<T>> action)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
PreviewLog.Write($"OnUiAsync 排队 {action.Method.Name}");
try
{
BeginInvoke(new Action(async () =>
{
try
{
T r = await action();
PreviewLog.Write($"OnUiAsync 完成 {action.Method.Name}");
tcs.SetResult(r);
}
catch (Exception ex) { tcs.SetException(ex); }
}));
}
catch (Exception ex)
{
tcs.SetException(ex);
}
return await tcs.Task;
}
/// <summary>
/// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度,
/// 只留最近若干个,供“当前播放地址取不到时”兜底用。
@@ -434,6 +466,300 @@ namespace WinFormsApp3
await DownloadCurrentVideoAsync();
}
private async void listBut_Click(object sender, EventArgs e)
{
await ShowVideoListAsync();
}
/// <summary>
/// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒),
/// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。
/// </summary>
private async Task ShowVideoListAsync()
{
if (webView21.CoreWebView2 == null)
{
SetStatus("页面还没加载好,请稍候");
return;
}
if (downloadCts != null)
{
SetStatus("已有下载在进行中,可先点“取消下载”");
return;
}
listBut.Enabled = false;
try
{
SetStatus("正在识别本页视频…");
string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript());
(List<VideoCandidate> items, HashSet<string> currentUrls) = CollectCandidates(raw);
if (items.Count == 0)
{
SetStatus("没识别到视频地址");
MessageBox.Show(this,
"本页没有识别到可下载的视频地址。\n\n" +
"常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n"
+ "2. 站点用了加密流(DRM),无法直接下载;\r\n"
+ "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。",
"下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
// 关键:列表窗口是模态对话框,实测对话框存在期间 WebView2 的异步回调
// (ExecuteScriptAsync / GetCookiesAsync)不会送达,要等窗口关闭才一次性返回。
// 所以所有依赖 WebView2 的数据必须在 ShowDialog 之前预取完毕,
// 后台预览任务只做 ffmpeg 抽帧,不再碰 WebView2。
SetStatus("正在取登录态与播放器画面…");
Dictionary<string, string?> cookies = new Dictionary<string, string?>();
foreach (VideoCandidate item in items)
{
if (!cookies.ContainsKey(item.Url))
{
cookies[item.Url] = await GetCookieHeaderAsync(item.Url);
}
}
List<byte[]> canvasFrames = await CaptureCurrentFramesAsync(CancellationToken.None);
Image? currentStrip = VideoListForm.ComposeFrames(canvasFrames);
PreviewLog.Write($"预取完成 cookies={cookies.Count} canvas帧={canvasFrames.Count} strip={(currentStrip != null ? "ok" : "null")}");
SetStatus($"识别到 {items.Count} 个视频,正在生成截图…");
Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> factory =
(c, ct) => BuildPreviewAsync(c, currentUrls.Contains(c.Url), currentStrip, cookies, ct);
using VideoListForm form = new VideoListForm(items, currentUrls, factory);
if (form.ShowDialog(this) != DialogResult.OK || form.SelectedCandidates.Count == 0)
{
SetStatus("已取消");
return;
}
using FolderBrowserDialog folderDlg = new FolderBrowserDialog
{
Description = $"选择保存文件夹(将下载 {form.SelectedCandidates.Count} 个视频)"
};
if (folderDlg.ShowDialog(this) != DialogResult.OK)
{
SetStatus("已取消");
return;
}
await DownloadCandidatesBatchAsync(form.SelectedCandidates, folderDlg.SelectedPath);
}
catch (OperationCanceledException)
{
SetStatus("已取消");
}
catch (Exception ex)
{
SetStatus($"识别失败:{ex.Message}");
MessageBox.Show(this, ex.Message, "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
finally
{
listBut.Enabled = true;
}
}
/// <summary>
/// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条
/// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。
/// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。
/// </summary>
private async Task<(Image? Image, string? Error)> BuildPreviewAsync(
VideoCandidate candidate, bool isCurrent, Image? currentStrip,
IReadOnlyDictionary<string, string?> cookies, CancellationToken ct)
{
string shortUrl = candidate.Url.Length > 70 ? candidate.Url[..70] : candidate.Url;
PreviewLog.Write($"预览开始 isCurrent={isCurrent} url={shortUrl}");
// 只在内存里的播放列表文本没有可 seek 的地址,不抽帧
if (candidate.PlaylistText != null)
{
return (null, "内存播放列表,无法抽帧预览");
}
if (isCurrent && currentStrip != null)
{
PreviewLog.Write("预览成功:播放器画面预取帧");
return (currentStrip, null);
}
try
{
string? referer = candidate.Referer ?? RefererFor(candidate.Url, null);
cookies.TryGetValue(candidate.Url, out string? cookie);
VideoDownloader downloader = new VideoDownloader(referer, cookie);
PreviewLog.Write("ffmpeg 抽帧开始");
VideoDownloader.FrameExtraction result = await downloader.ExtractFramesAsync(candidate.Url, ct);
Image? img = VideoListForm.ComposeFrames(result.Frames);
PreviewLog.Write($"ffmpeg 抽帧结束 frames={result.Frames.Count} error={result.Error ?? "-"}");
return img != null ? (img, null) : (null, result.Error ?? "没抽到帧");
}
catch (OperationCanceledException)
{
PreviewLog.Write("预览被取消");
throw;
}
catch (Exception ex)
{
PreviewLog.Write($"预览异常 {ex.GetType().Name}: {ex.Message}");
return (null, ex.Message);
}
}
/// <summary>
/// 从正在播放的 <video> 元素里截 3 帧(2/7/12 秒,对应“每 5 秒 3 张”):
/// 临时暂停→逐帧 seek+canvas 绘制→恢复原进度与播放状态;单次 seek 限 3 秒,
/// 遇到 tainted canvas(跨域污染)等异常就放弃,退回 ffmpeg 远程抽帧。
/// </summary>
private async Task<List<byte[]>> CaptureCurrentFramesAsync(CancellationToken ct)
{
List<byte[]> frames = new List<byte[]>();
try
{
if (webView21.CoreWebView2 == null) return frames;
// 后台线程不能直接碰 ExecuteScriptAsync,封送回 UI 线程;
// 整条脚本再加 20 秒硬超时,页面播放器抽风时不能把预览任务永久挂住
using var overall = CancellationTokenSource.CreateLinkedTokenSource(ct);
overall.CancelAfter(TimeSpan.FromSeconds(20));
string raw;
try
{
raw = await OnUiAsync(() => webView21.CoreWebView2!.ExecuteScriptAsync(CurrentFramesScript))
.WaitAsync(overall.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
PreviewLog.Write($"播放器取帧脚本超时/取消:{ex.GetType().Name}");
return frames;
}
ct.ThrowIfCancellationRequested();
PreviewLog.Write($"播放器取帧脚本返回 {raw.Length} 字符");
string? inner = JsonSerializer.Deserialize<string>(raw);
if (string.IsNullOrEmpty(inner)) return frames;
if (JsonNode.Parse(inner)?["frames"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? dataUrl = item?.GetValue<string>();
int comma = dataUrl?.IndexOf(',') ?? -1;
if (dataUrl == null || comma < 0) continue;
try { frames.Add(Convert.FromBase64String(dataUrl[(comma + 1)..])); }
catch { /* 坏帧跳过 */ }
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch
{
// 播放器截图失败就退回 ffmpeg 远程抽帧
}
return frames;
}
private const string CurrentFramesScript = @"
(async function(){
function findVideo(){
var vids=Array.prototype.slice.call(document.querySelectorAll('video'));
for(var i=0;i<vids.length;i++){ if(!vids[i].paused && vids[i].currentTime>0) return vids[i]; }
for(var j=0;j<vids.length;j++){ if(vids[j].videoWidth>0) return vids[j]; }
return vids[0]||null;
}
var v=findVideo();
if(!v) return JSON.stringify({frames:[]});
var times=[2,7,12], frames=[], wasPaused=v.paused, t0=v.currentTime;
try{
v.pause();
for(var k=0;k<times.length;k++){
var t=times[k];
if(isFinite(v.duration) && t>v.duration-0.2) t=v.duration>1?v.duration-0.5:0;
var ok=true;
try{
await new Promise(function(res,rej){
var done=false;
var to=setTimeout(function(){ if(!done){done=true;rej('t');} },3000);
v.onseeked=function(){ if(!done){done=true;clearTimeout(to);res();} };
try{ v.currentTime=t; }catch(e){ if(!done){done=true;clearTimeout(to);rej('s');} }
});
}catch(e){ ok=false; }
if(!ok) break;
try{
var c=document.createElement('canvas');
var w=v.videoWidth||480, h=v.videoHeight||270;
var sc=Math.min(1,480/w);
c.width=Math.round(w*sc); c.height=Math.round(h*sc);
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
frames.push(c.toDataURL('image/jpeg',0.85));
}catch(e){ frames=[]; break; }
}
} finally {
// 注意:这里绝不能 await v.play()——抖音播放器会干预 play 的 Promise,
// 一旦 pending 不结束整段脚本永不返回,截图任务会永久挂死
try{
v.currentTime=t0;
if(!wasPaused){ try{ v.play().catch(function(){}); }catch(e){} }
}catch(e){}
}
return JSON.stringify({frames:frames});
})()";
/// <summary>
/// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并;
/// 某条失败时询问是否继续,用户点取消则中止剩余条目。
/// </summary>
private async Task DownloadCandidatesBatchAsync(List<VideoCandidate> items, string folder)
{
int succeeded = 0;
List<string> failures = new List<string>();
VideoDownloader namer = new VideoDownloader(RefererFor(items[0].Url, webView21.CoreWebView2?.Source));
for (int i = 0; i < items.Count; i++)
{
VideoCandidate item = items[i];
string fileName = namer.SuggestedFileName(item.Url, PageTitle);
string filePath = UniqueFilePath(folder, fileName);
SetStatus($"批量下载 {i + 1}/{items.Count}:{fileName}");
downloadWasCanceled = false;
string? failure = await TryDownloadAsync(item, filePath, promptOpenFolder: false);
if (downloadWasCanceled) break; // 用户取消整个批次
if (failure == null)
{
succeeded++;
}
else
{
failures.Add($"{fileName}:{failure}");
if (i < items.Count - 1 && MessageBox.Show(this,
$"第 {i + 1} 个下载失败:\n{failure}\n\n跳过它继续下载剩下的吗?",
"批量下载", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
{
break;
}
}
}
string summary = $"成功 {succeeded} 个,失败 {failures.Count} 个(共 {items.Count})。";
SetStatus($"批量下载完成:{summary}");
if (failures.Count > 0)
{
summary += "\n\n" + string.Join("\n", failures.Take(8)) + (failures.Count > 8 ? "\n…" : string.Empty);
}
MessageBox.Show(this, summary, "批量下载", MessageBoxButtons.OK,
failures.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
if (succeeded > 0) OpenFolder(folder);
}
/// <summary>
/// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。
/// </summary>
@@ -512,40 +838,13 @@ namespace WinFormsApp3
/// </summary>
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
{
string current = string.Empty;
string pageUrl = string.Empty;
string dashVideo = string.Empty;
string dashAudio = string.Empty;
string videoId = 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;
dashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
dashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
videoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
if (node?["sniffed"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? u = item?.GetValue<string>();
if (!string.IsNullOrEmpty(u)) sniffed.Add(u);
}
}
}
}
catch
{
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
}
GrabInfo g = ParseGrab(grabResultJson);
string pageUrl = g.PageUrl;
List<string> sniffed = g.Sniffed;
string dashVideo = g.DashVideo;
string dashAudio = g.DashAudio;
string videoId = g.VideoId;
string current = g.Current;
// 第一层:video 标签自己的播放源
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
@@ -594,6 +893,125 @@ namespace WinFormsApp3
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
}
/// <summary>
/// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。
/// </summary>
private sealed class GrabInfo
{
public string Current = string.Empty;
public string PageUrl = string.Empty;
public string DashVideo = string.Empty;
public string DashAudio = string.Empty;
public string VideoId = string.Empty;
public List<string> Sniffed = new();
}
private static GrabInfo ParseGrab(string grabResultJson)
{
GrabInfo g = new GrabInfo();
try
{
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
if (!string.IsNullOrEmpty(inner))
{
JsonNode? node = JsonNode.Parse(inner);
g.Current = node?["current"]?.GetValue<string>() ?? string.Empty;
g.PageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
g.DashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
g.DashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
g.VideoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
if (node?["sniffed"] is JsonArray arr)
{
foreach (JsonNode? item in arr)
{
string? u = item?.GetValue<string>();
if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!);
}
}
}
}
catch
{
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
}
return g;
}
/// <summary>
/// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前,
/// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。
/// </summary>
private (List<VideoCandidate> Items, HashSet<string> CurrentUrls) CollectCandidates(string grabResultJson)
{
GrabInfo g = ParseGrab(grabResultJson);
List<VideoCandidate> items = new List<VideoCandidate>();
HashSet<string> currentUrls = new HashSet<string>();
void Add(string? url, string? audio, bool isCurrent)
{
if (string.IsNullOrEmpty(url)
|| !VideoDownloader.LooksLikeMedia(url)
|| VideoDownloader.IsSegmentUrl(url)
|| VideoDownloader.IsDashAudioOnly(url))
{
return;
}
if (items.Any(i => i.Url == url))
{
if (isCurrent) currentUrls.Add(url!);
return;
}
// 只有 DASH 纯视频轨才需要配音频轨;普通整片直链自带音轨,乱配反而会把别人的声音合进去
string? pairedAudio = audio ?? (VideoDownloader.IsDashVideoOnly(url)
? FindPairedAudio(url!, isCurrent ? g.VideoId : IdFromUrl(url!))
: null);
items.Add(new VideoCandidate(url, null, RefererFor(url, g.PageUrl), pairedAudio));
if (isCurrent) currentUrls.Add(url!);
}
// 当前播放的两条源排最前,方便和下面的嗅探条目肉眼对比
Add(g.Current, null, true);
Add(g.DashVideo, string.IsNullOrEmpty(g.DashAudio) ? null : g.DashAudio, true);
List<string> pool = g.Sniffed.Concat(SnapshotSniffedUrls())
.Distinct()
.ToList();
// 有视频 ID 时先把同 ID 的地址排前面,它们多半就是当前视频
if (!string.IsNullOrEmpty(g.VideoId))
{
pool = pool.OrderByDescending(u => u.Contains(g.VideoId)).ToList();
}
// 嗅探列表里越新的越接近当前视频,倒序追加
for (int i = pool.Count - 1; i >= 0; i--)
{
string url = pool[i];
Add(url, null, !string.IsNullOrEmpty(g.VideoId) && url.Contains(g.VideoId));
}
return (items, currentUrls);
}
/// <summary>
/// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。
/// </summary>
private static string IdFromUrl(string url)
{
try
{
Match m = Regex.Match(new Uri(url).AbsolutePath, @"\d{10,}");
return m.Success ? m.Value : string.Empty;
}
catch
{
return string.Empty;
}
}
/// <summary>
/// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。
/// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。
@@ -676,8 +1094,9 @@ namespace WinFormsApp3
/// <summary>
/// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。
/// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。
/// </summary>
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath)
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true)
{
VideoDownloader downloader = await CreateDownloaderAsync(candidate);
@@ -706,7 +1125,7 @@ namespace WinFormsApp3
SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})");
if (MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?",
if (promptOpenFolder && MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?",
"下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes)
{
OpenFolder(result.FilePath);
@@ -716,7 +1135,8 @@ namespace WinFormsApp3
}
catch (OperationCanceledException)
{
// 用户主动取消就别再换地址重试了
// 用户主动取消就别再换地址重试了;批量路径靠这个标志中止剩余条目
downloadWasCanceled = true;
SetStatus("已取消下载");
return null;
}
@@ -745,7 +1165,11 @@ namespace WinFormsApp3
return new VideoDownloader(referer, cookie);
}
private async Task<string?> GetCookieHeaderAsync(string url)
/// <summary>
/// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程,
/// 否则截图预览的后台任务会在这里永久卡死。
/// </summary>
private Task<string?> GetCookieHeaderAsync(string url) => OnUiAsync(async () =>
{
CoreWebView2? core = webView21.CoreWebView2;
if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null;
@@ -763,7 +1187,7 @@ namespace WinFormsApp3
// 拿不到 Cookie 就按匿名请求下载
return null;
}
}
});
private void ReportProgress(DownloadProgress value)
{
@@ -801,6 +1225,23 @@ namespace WinFormsApp3
}
}
/// <summary>
/// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。
/// </summary>
private static string UniqueFilePath(string folder, string fileName)
{
string path = Path.Combine(folder, fileName);
if (!File.Exists(path)) return path;
string name = Path.GetFileNameWithoutExtension(fileName);
string ext = Path.GetExtension(fileName);
for (int i = 2; ; i++)
{
path = Path.Combine(folder, $"{name} ({i}){ext}");
if (!File.Exists(path)) return path;
}
}
private static void OpenFolder(string filePath)
{
try