Merge branch 'main' of https://github.youjiala.com/itcsx/Docker
# Conflicts: # uniapp/security/src/manifest.json
No files matched your search
@@ -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);
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,260 @@
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<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>
|
||||
<!-- _hostest 是独立测试项目,不能被主项目编译 -->
|
||||
<Compile Remove="_hostest/**/*.cs" />
|
||||
<None Remove="_hostest/**/*" />
|
||||
</ItemGroup>
|
||||
|
||||
<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>
|
||||
<None Update="ffmpeg.json">
|
||||
<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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace WinFormsApp3
|
||||
{
|
||||
/// <summary>
|
||||
/// 截图预览诊断日志:exe 目录下 preview_debug.log。
|
||||
/// 挂起类问题光看界面定位不到,必须把每一步(含线程号)落到磁盘上。
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
// 日志写失败不影响功能
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 344 B |
|
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) {}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 630 B |
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 816 B |
|
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,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 解析失败就走嗅探兜底 */ }
|
||||
|
||||
// === 方案二:选正在播放的 <video> 元素 ===
|
||||
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.paused && v.currentTime > 0) { best = v; break; }
|
||||
}
|
||||
if (!best) {
|
||||
for (var j = 0; j < vids.length; j++) {
|
||||
var v2 = vids[j];
|
||||
if (v2.videoWidth > 0 || (!isNaN(v2.duration) && v2.duration > 0 && v2.currentTime > 0)) {
|
||||
best = v2;
|
||||
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) playable = false;
|
||||
}
|
||||
|
||||
// === 方案三:嗅探列表兜底 ===
|
||||
var all = window.__mediaUrls || [];
|
||||
var videoTracks = [];
|
||||
var audioTracks = [];
|
||||
var otherMedia = [];
|
||||
for (var k = 0; k < all.length; k++) {
|
||||
var u = all[k];
|
||||
if (/media-video/i.test(u)) videoTracks.push(u);
|
||||
else if (/media-audio/i.test(u)) audioTracks.push(u);
|
||||
else otherMedia.push(u);
|
||||
}
|
||||
|
||||
function pickLast(arr) {
|
||||
return arr.length ? arr[arr.length - 1] : "";
|
||||
}
|
||||
|
||||
var dashVideo = pickLast(videoTracks);
|
||||
var dashAudio = pickLast(audioTracks);
|
||||
|
||||
// 如果 RENDER_DATA 找到了,优先用它
|
||||
if (renderVideoUrl) {
|
||||
dashVideo = renderVideoUrl;
|
||||
if (renderAudioUrl) dashAudio = renderAudioUrl;
|
||||
}
|
||||
|
||||
var title = document.title || "";
|
||||
var h1 = document.querySelector("h1");
|
||||
if (h1 && h1.innerText) title = h1.innerText;
|
||||
|
||||
return JSON.stringify({
|
||||
current: current || "",
|
||||
playable: playable,
|
||||
usesMse: !!window.__usesMse || /^blob:/i.test(current),
|
||||
title: title,
|
||||
pageUrl: pageUrl,
|
||||
videoId: videoId,
|
||||
sniffed: otherMedia.slice(-60),
|
||||
dashVideo: dashVideo,
|
||||
dashAudio: dashAudio,
|
||||
m3u8Blobs: (window.__m3u8Blobs || []).slice(-1),
|
||||
});
|
||||
} catch (e) {
|
||||
return JSON.stringify({
|
||||
current: "",
|
||||
title: document.title || "",
|
||||
pageUrl: location.href,
|
||||
error: String(e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -0,0 +1,886 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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>
|
||||
/// 判断是否是 DASH 纯音频轨地址(抖音 .../media-audio-xxx/...)。
|
||||
/// 这种地址单独下载出来没有画面,必须和视频轨合并。
|
||||
/// </summary>
|
||||
public static bool IsDashAudioOnly(string? url) =>
|
||||
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-audio[/-]", RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否是 DASH 纯视频轨地址(抖音 .../media-video-xxx/...)。
|
||||
/// 这种地址单独下载出来没有声音,需要配音频轨。
|
||||
/// </summary>
|
||||
public static bool IsDashVideoOnly(string? url) =>
|
||||
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-video[/-]", 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载 DASH 分离的视频轨和音频轨,再用 ffmpeg 合并成单个 mp4。
|
||||
/// 抖音等站点把视频和音频分成两个独立的 mp4 轨道返回,必须合并才能有声有画。
|
||||
/// </summary>
|
||||
public async Task<DownloadResult> DownloadDashPairAsync(string videoUrl, string audioUrl, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
|
||||
{
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg == null)
|
||||
{
|
||||
throw new NotSupportedException("下载抖音 DASH 分离视频轨需要 ffmpeg 合并音视频,但未找到 ffmpeg.exe。请把 ffmpeg.exe 放到程序目录下。");
|
||||
}
|
||||
|
||||
using var client = CreateClient();
|
||||
|
||||
// 临时文件
|
||||
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tmpDir);
|
||||
var videoTmp = Path.Combine(tmpDir, "video.mp4");
|
||||
var audioTmp = Path.Combine(tmpDir, "audio.mp4");
|
||||
|
||||
try
|
||||
{
|
||||
// 下载视频轨(占总进度 0~50%)
|
||||
progress?.Report(new DownloadProgress("下载视频轨…", 0));
|
||||
await DownloadSingleToFileAsync(client, videoUrl, videoTmp, ct,
|
||||
p => progress?.Report(new DownloadProgress($"下载视频轨 {FormatSize(p)}", null)));
|
||||
var videoSize = new FileInfo(videoTmp).Length;
|
||||
|
||||
// 下载音频轨(占总进度 50~100%)
|
||||
progress?.Report(new DownloadProgress("下载音频轨…", 0.5));
|
||||
await DownloadSingleToFileAsync(client, audioUrl, audioTmp, ct,
|
||||
p => progress?.Report(new DownloadProgress($"下载音频轨 {FormatSize(p)}", null)));
|
||||
var audioSize = new FileInfo(audioTmp).Length;
|
||||
|
||||
// 合并
|
||||
progress?.Report(new DownloadProgress("合并音视频…", 0.95));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(filePath))!);
|
||||
var ok = await MuxDashAsync(ffmpeg, videoTmp, audioTmp, filePath, ct);
|
||||
if (!ok)
|
||||
throw new InvalidOperationException("ffmpeg 合并音视频失败");
|
||||
|
||||
var finalSize = new FileInfo(filePath).Length;
|
||||
progress?.Report(new DownloadProgress($"完成 {FormatSize(finalSize)}", 1));
|
||||
return new DownloadResult(filePath, finalSize, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载单个文件到指定路径(不做 .part 重命名,内部用)。
|
||||
/// </summary>
|
||||
private async Task DownloadSingleToFileAsync(HttpClient client, string url, string filePath, CancellationToken ct, Action<long>? onBytes = null)
|
||||
{
|
||||
using var resp = await GetFollowRedirectAsync(client, url, ct);
|
||||
EnsureVideoResponse(resp);
|
||||
|
||||
await using var input = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var output = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, true);
|
||||
var buffer = new byte[1024 * 64];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await input.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
total += read;
|
||||
onBytes?.Invoke(total);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。
|
||||
/// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确;
|
||||
/// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。
|
||||
/// </summary>
|
||||
private static async Task<bool> MuxDashAsync(string ffmpeg, string videoFile, string audioFile, string outputFile, CancellationToken ct)
|
||||
{
|
||||
// 只取视频轨的视频流 + 音频轨的音频流(没音频流也照常出片),-shortest 把两条轨截齐到同一时长
|
||||
var copyArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c copy -shortest -movflags +faststart \"{outputFile}\"";
|
||||
if (await RunFfmpegAsync(ffmpeg, copyArgs, outputFile, ct)) return true;
|
||||
|
||||
TryDelete(outputFile);
|
||||
|
||||
var aacArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c:v copy -c:a aac -strict experimental -shortest -movflags +faststart \"{outputFile}\"";
|
||||
return await RunFfmpegAsync(ffmpeg, aacArgs, outputFile, ct);
|
||||
}
|
||||
|
||||
private static async Task<bool> RunFfmpegAsync(string ffmpeg, string args, string outputFile, CancellationToken ct)
|
||||
{
|
||||
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(outputFile) && new FileInfo(outputFile).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽帧结果:成功时 Frames 非空;失败时 Error 给界面显示原因,不让用户干等。
|
||||
/// </summary>
|
||||
public record FrameExtraction(List<byte[]> Frames, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// 抽取预览截图:每 intervalSeconds 秒取一张、共取 frameCount 张(默认 5 秒 3 张),
|
||||
/// 供下载列表肉眼对比哪个是当前视频。用 ffmpeg 直接读远端地址,带 Referer/Cookie 过防盗链。
|
||||
/// 单次尝试限 25 秒,超时不再重试第二次,避免地址失效时列表长时间卡在“生成中”。
|
||||
/// </summary>
|
||||
public async Task<FrameExtraction> ExtractFramesAsync(string url, CancellationToken ct, int intervalSeconds = 5, int frameCount = 3)
|
||||
{
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg == null)
|
||||
{
|
||||
return new FrameExtraction(new List<byte[]>(), "未找到 ffmpeg,无法生成截图");
|
||||
}
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return new FrameExtraction(new List<byte[]>(), "该地址无法直接抽帧");
|
||||
}
|
||||
|
||||
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tmpDir);
|
||||
|
||||
try
|
||||
{
|
||||
string? lastError = null;
|
||||
|
||||
// 先试从第 2 秒开始抽(片头常有黑帧),只有上一次正常退出且没拿到帧才退回 0 秒再试
|
||||
foreach (var startAt in new[] { 2, 0 })
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"-hide_banner", "-loglevel", "error", "-nostdin", "-y",
|
||||
// 远端卡住时快速失败:单次 I/O 12 秒,探测上限 2MB/2秒,不靠外部杀进程兑底
|
||||
"-rw_timeout", "12000000",
|
||||
"-probesize", "2000000", "-analyzeduration", "2000000",
|
||||
"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"
|
||||
};
|
||||
var headers = BuildFfmpegHeaders();
|
||||
if (headers.Length > 0)
|
||||
{
|
||||
args.Add("-headers");
|
||||
args.Add(headers);
|
||||
}
|
||||
if (IsHlsUrl(url))
|
||||
{
|
||||
args.Add("-protocol_whitelist");
|
||||
args.Add("file,http,https,tcp,tls,crypto");
|
||||
}
|
||||
args.AddRange(new[]
|
||||
{
|
||||
"-ss", startAt.ToString(), "-i", url,
|
||||
"-t", (intervalSeconds * frameCount).ToString(),
|
||||
"-vf", $"fps=1/{intervalSeconds},scale=160:-2",
|
||||
"-frames:v", frameCount.ToString(), "-an",
|
||||
Path.Combine(tmpDir, "f_%d.jpg")
|
||||
});
|
||||
|
||||
var run = await RunFfmpegArgsAsync(ffmpeg, args, ct);
|
||||
|
||||
var files = Directory.GetFiles(tmpDir, "f_*.jpg").OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||
if (files.Count > 0)
|
||||
{
|
||||
return new FrameExtraction(files.Select(File.ReadAllBytes).ToList(), null);
|
||||
}
|
||||
|
||||
lastError = run.Error ?? "没抽到帧";
|
||||
if (run.TimedOut)
|
||||
{
|
||||
break; // 地址多半已失效或被防盗链卡住,再试一次只会多等半分钟
|
||||
}
|
||||
}
|
||||
|
||||
return new FrameExtraction(new List<byte[]>(), lastError ?? "没抽到帧");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ffmpeg 走网络也要过防盗链:把下载器身上的 UA / Referer / Cookie 拼成 -headers 参数。
|
||||
/// </summary>
|
||||
private string BuildFfmpegHeaders()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"User-Agent: {UserAgent}\r\n");
|
||||
if (_referer != null) sb.Append($"Referer: {_referer}\r\n");
|
||||
if (_cookie != null) sb.Append($"Cookie: {_cookie}\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>ffmpeg 单次运行结果:TimedOut 表示被外部杀掉,Error 给界面显示原因。</summary>
|
||||
private record FfmpegRun(bool TimedOut, string? Error);
|
||||
|
||||
private static async Task<FfmpegRun> RunFfmpegArgsAsync(string ffmpeg, List<string> args, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpeg,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return new FfmpegRun(false, "ffmpeg 启动失败");
|
||||
|
||||
// 就算 -rw_timeout 失效(DNS 卡住、TLS 握手挂起等),25 秒也必须杀掉
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(25));
|
||||
|
||||
var errRead = proc.StandardError.ReadToEndAsync(); // 不读干 stderr 会把进程堵死
|
||||
bool timedOut = false;
|
||||
using (timeout.Token.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
|
||||
{
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw; // 用户取消要往上抛,超时杀掉进程则返回原因
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
timedOut = true;
|
||||
}
|
||||
}
|
||||
|
||||
string stderr = string.Empty;
|
||||
try { stderr = await errRead; } catch { /* 进程被杀时读流可能报错,不影响判断 */ }
|
||||
|
||||
if (timedOut)
|
||||
{
|
||||
return new FfmpegRun(true, "抽帧超时(25秒):地址可能已失效或被防盗链卡住");
|
||||
}
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
return new FfmpegRun(false, SummarizeFfmpegError(stderr));
|
||||
}
|
||||
return new FfmpegRun(false, null);
|
||||
}
|
||||
|
||||
/// <summary>取 ffmpeg 报错里最有信息量的一行,太长截断,直接丢给界面显示。</summary>
|
||||
private static string SummarizeFfmpegError(string stderr)
|
||||
{
|
||||
var line = (stderr ?? string.Empty)
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim('\r', ' '))
|
||||
.FirstOrDefault(l => l.Length > 0 && !l.StartsWith("[h264", StringComparison.OrdinalIgnoreCase)
|
||||
&& !l.StartsWith("[aac", StringComparison.OrdinalIgnoreCase));
|
||||
if (string.IsNullOrEmpty(line)) return "ffmpeg 抽帧失败";
|
||||
return line.Length > 90 ? line[..90] + "…" : line;
|
||||
}
|
||||
|
||||
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>
|
||||
/// 真正的视频容器不会以 "<" 或 "{" 开头;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:顺序为 1) ffmpeg.json 配置文件指定的路径;2) 程序目录 ffmpeg.exe;3) PATH。
|
||||
/// 配置文件格式:{"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"}
|
||||
/// </summary>
|
||||
public static string? FindFfmpeg()
|
||||
{
|
||||
// 1. 配置文件指定的路径(优先级最高)
|
||||
var configPath = Path.Combine(AppContext.BaseDirectory, "ffmpeg.json");
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("ffmpegPath", out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var configured = prop.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
return configured;
|
||||
}
|
||||
}
|
||||
catch { /* 配置文件损坏就跳过,继续找别的 */ }
|
||||
}
|
||||
|
||||
// 2. 程序目录
|
||||
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
|
||||
if (File.Exists(local)) return local;
|
||||
|
||||
// 3. PATH
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
namespace WinFormsApp3
|
||||
{
|
||||
partial class VideoListForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
components?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
components = new System.ComponentModel.Container();
|
||||
pnlVideos = new Panel();
|
||||
panelBottom = new Panel();
|
||||
lblHint = new Label();
|
||||
flowButtons = new FlowLayoutPanel();
|
||||
btnDownload = new Button();
|
||||
btnCancel = new Button();
|
||||
SuspendLayout();
|
||||
//
|
||||
// pnlVideos
|
||||
//
|
||||
pnlVideos.AutoScroll = true;
|
||||
pnlVideos.Dock = DockStyle.Fill;
|
||||
pnlVideos.Name = "pnlVideos";
|
||||
//
|
||||
// panelBottom
|
||||
//
|
||||
panelBottom.Controls.Add(flowButtons);
|
||||
panelBottom.Controls.Add(lblHint);
|
||||
panelBottom.Dock = DockStyle.Bottom;
|
||||
panelBottom.Height = 48;
|
||||
panelBottom.Name = "panelBottom";
|
||||
//
|
||||
// lblHint
|
||||
//
|
||||
lblHint.AutoSize = true;
|
||||
lblHint.Location = new Point(12, 15);
|
||||
lblHint.Name = "lblHint";
|
||||
lblHint.Text = "正在生成预览截图…";
|
||||
//
|
||||
// flowButtons
|
||||
// 用从右往左的流式布局放按钮,避免 Anchor+固定坐标在初始化时被锚点公式推出窗外
|
||||
//
|
||||
flowButtons.Controls.Add(btnCancel);
|
||||
flowButtons.Controls.Add(btnDownload);
|
||||
flowButtons.Dock = DockStyle.Right;
|
||||
flowButtons.FlowDirection = FlowDirection.RightToLeft;
|
||||
flowButtons.Location = new Point(600, 0);
|
||||
flowButtons.Name = "flowButtons";
|
||||
flowButtons.Size = new Size(300, 48);
|
||||
flowButtons.WrapContents = false;
|
||||
//
|
||||
// btnDownload
|
||||
//
|
||||
btnDownload.Margin = new Padding(0, 9, 8, 9);
|
||||
btnDownload.Name = "btnDownload";
|
||||
btnDownload.Size = new Size(150, 30);
|
||||
btnDownload.Text = "下载选中 (0)";
|
||||
btnDownload.UseVisualStyleBackColor = true;
|
||||
btnDownload.Click += btnDownload_Click;
|
||||
//
|
||||
// btnCancel
|
||||
//
|
||||
btnCancel.DialogResult = DialogResult.Cancel;
|
||||
btnCancel.Margin = new Padding(0, 9, 4, 9);
|
||||
btnCancel.Name = "btnCancel";
|
||||
btnCancel.Size = new Size(88, 30);
|
||||
btnCancel.Text = "取消";
|
||||
btnCancel.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// VideoListForm
|
||||
//
|
||||
CancelButton = btnCancel;
|
||||
AcceptButton = btnDownload;
|
||||
ClientSize = new Size(900, 640);
|
||||
Controls.Add(pnlVideos);
|
||||
Controls.Add(panelBottom);
|
||||
MinimumSize = new Size(640, 400);
|
||||
Name = "VideoListForm";
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
Text = "本页视频列表(截图预览,勾选后批量下载)";
|
||||
panelBottom.ResumeLayout(false);
|
||||
panelBottom.PerformLayout();
|
||||
ResumeLayout(false);
|
||||
}
|
||||
|
||||
private Panel pnlVideos;
|
||||
private Panel panelBottom;
|
||||
private Label lblHint;
|
||||
private FlowLayoutPanel flowButtons;
|
||||
private Button btnDownload;
|
||||
private Button btnCancel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
|
||||
namespace WinFormsApp3
|
||||
{
|
||||
/// <summary>
|
||||
/// 本页视频下载列表:每个候选视频用 ffmpeg 抽 3 张间隔 5 秒的截图拼成预览条,
|
||||
/// 肉眼对比哪个是当前视频后勾选,选文件夹批量下载。
|
||||
/// 当前正在播放的那条默认勾上。
|
||||
/// </summary>
|
||||
public partial class VideoListForm : Form
|
||||
{
|
||||
private const int RowHeight = 124;
|
||||
private const int PreviewWidth = 496;
|
||||
private const int PreviewHeight = 100;
|
||||
|
||||
private readonly Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> _previewFactory;
|
||||
private readonly List<VideoRow> _rows = new();
|
||||
private readonly CancellationTokenSource _loadCts = new();
|
||||
private int _generatedCount;
|
||||
private int _failedCount;
|
||||
|
||||
/// <summary>点“下载选中”后带回的勾选候选(按列表顺序)。</summary>
|
||||
public List<VideoCandidate> SelectedCandidates { get; } = new();
|
||||
|
||||
public VideoListForm(IReadOnlyList<VideoCandidate> candidates, IReadOnlyCollection<string> currentUrls,
|
||||
Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> previewFactory)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_previewFactory = previewFactory;
|
||||
BuildRows(candidates, currentUrls);
|
||||
}
|
||||
|
||||
private void BuildRows(IReadOnlyList<VideoCandidate> candidates, IReadOnlyCollection<string> currentUrls)
|
||||
{
|
||||
int y = 8;
|
||||
foreach (VideoCandidate candidate in candidates)
|
||||
{
|
||||
bool isCurrent = currentUrls.Contains(candidate.Url);
|
||||
VideoRow row = new VideoRow(candidate, Describe(candidate, isCurrent), isCurrent)
|
||||
{
|
||||
Location = new Point(8, y),
|
||||
Width = Math.Max(620, pnlVideos.ClientSize.Width - 24),
|
||||
};
|
||||
row.CheckChanged += (_, _) => UpdateSelectionCount();
|
||||
_rows.Add(row);
|
||||
pnlVideos.Controls.Add(row);
|
||||
y += RowHeight + 6;
|
||||
}
|
||||
|
||||
pnlVideos.Resize += (_, _) =>
|
||||
{
|
||||
foreach (VideoRow row in _rows)
|
||||
{
|
||||
row.Width = Math.Max(620, pnlVideos.ClientSize.Width - 24);
|
||||
}
|
||||
};
|
||||
|
||||
UpdateSelectionCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 截图预览在后台生成(三条并发),生成一条刷新一条;
|
||||
/// 失败时把原因直接写在预览条上,不让界面一直停在“生成中…”。
|
||||
/// </summary>
|
||||
protected override async void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
|
||||
if (_rows.Count == 0)
|
||||
{
|
||||
lblHint.Text = "本页没有可下载的视频";
|
||||
return;
|
||||
}
|
||||
|
||||
SemaphoreSlim semaphore = new SemaphoreSlim(3);
|
||||
Task[] tasks = _rows.Select(async row =>
|
||||
{
|
||||
await semaphore.WaitAsync(_loadCts.Token);
|
||||
try
|
||||
{
|
||||
Image? preview = null;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
(preview, error) = await _previewFactory(row.Candidate, _loadCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
PreviewLog.Write("预览任务被取消(列表已关闭)");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 抽帧失败按“无预览”处理,勾选与下载不受影响
|
||||
error = ex.Message;
|
||||
}
|
||||
|
||||
PreviewLog.Write($"预览回传 UI img={(preview != null ? "ok" : "null")} error={(error ?? "-")}");
|
||||
|
||||
if (_loadCts.IsCancellationRequested || !IsHandleCreated || IsDisposed)
|
||||
{
|
||||
preview?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
row.SetResult(preview, error);
|
||||
if (preview == null) _failedCount++;
|
||||
_generatedCount++;
|
||||
// 最后一行刷新时直接切到完成文案,不依赖 WhenAll 后续代码的时序
|
||||
lblHint.Text = _generatedCount < _rows.Count
|
||||
? $"截图生成中… {_generatedCount}/{_rows.Count}" +
|
||||
(_failedCount > 0 ? $"({_failedCount} 条失败,原因见预览条)" : string.Empty)
|
||||
: (_failedCount == 0
|
||||
? "截图已生成,对比后勾选要下载的视频"
|
||||
: $"截图生成完毕({_failedCount} 条失败,原因见预览条;不影响勾选下载)");
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
if (!IsDisposed && !_loadCts.IsCancellationRequested)
|
||||
{
|
||||
lblHint.Text = _failedCount == 0
|
||||
? "截图已生成,对比后勾选要下载的视频"
|
||||
: $"截图生成完毕({_failedCount} 条失败,原因见预览条;不影响勾选下载)";
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
_loadCts.Cancel();
|
||||
_loadCts.Dispose();
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
|
||||
private void UpdateSelectionCount()
|
||||
{
|
||||
btnDownload.Text = $"下载选中 ({_rows.Count(r => r.IsChecked)})";
|
||||
}
|
||||
|
||||
private void btnDownload_Click(object sender, EventArgs e)
|
||||
{
|
||||
SelectedCandidates.Clear();
|
||||
SelectedCandidates.AddRange(_rows.Where(r => r.IsChecked).Select(r => r.Candidate));
|
||||
|
||||
if (SelectedCandidates.Count == 0)
|
||||
{
|
||||
MessageBox.Show(this, "先勾选要下载的视频(看截图对比哪个是当前视频)。",
|
||||
"下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
}
|
||||
|
||||
private static string Describe(VideoCandidate candidate, bool isCurrent)
|
||||
{
|
||||
List<string> tags = new List<string>();
|
||||
if (isCurrent) tags.Add("当前播放");
|
||||
if (candidate.PlaylistText != null) tags.Add("内存播放列表");
|
||||
else if (VideoDownloader.IsHlsUrl(candidate.Url)) tags.Add("m3u8");
|
||||
if (!string.IsNullOrEmpty(candidate.AudioUrl)) tags.Add("音频轨已配对·自动合并");
|
||||
if (tags.Count == 0) tags.Add("直链");
|
||||
|
||||
string hostLine;
|
||||
try
|
||||
{
|
||||
Uri u = new Uri(candidate.Url);
|
||||
string last = Path.GetFileName(u.AbsolutePath);
|
||||
hostLine = last.Length > 0 ? $"{u.Host} … {last}" : u.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
hostLine = candidate.Url.Length > 90 ? candidate.Url[..90] + "…" : candidate.Url;
|
||||
}
|
||||
|
||||
return $"【{string.Join("】【", tags)}】\n{hostLine}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把抽到的 JPEG 帧横向拼成一条预览图(约 3×160 宽),供列表行显示。
|
||||
/// </summary>
|
||||
public static Image? ComposeFrames(List<byte[]> frames)
|
||||
{
|
||||
if (frames == null || frames.Count == 0) return null;
|
||||
|
||||
List<Image> source = new List<Image>();
|
||||
foreach (byte[] frame in frames)
|
||||
{
|
||||
try
|
||||
{
|
||||
Image img = Image.FromStream(new MemoryStream(frame));
|
||||
source.Add(img);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 坏帧直接跳过
|
||||
}
|
||||
}
|
||||
if (source.Count == 0) return null;
|
||||
|
||||
const int cellW = 160;
|
||||
const int gap = 8;
|
||||
int cellH = PreviewHeight - 4;
|
||||
|
||||
var sizes = source.Select(img =>
|
||||
{
|
||||
int w = cellW;
|
||||
int h = Math.Max(1, (int)Math.Round((double)cellW * img.Height / img.Width));
|
||||
if (h > cellH)
|
||||
{
|
||||
h = cellH;
|
||||
w = Math.Max(1, (int)Math.Round((double)cellH * img.Width / img.Height));
|
||||
}
|
||||
return (w, h);
|
||||
}).ToList();
|
||||
|
||||
int canvasW = source.Count * cellW + (source.Count - 1) * gap;
|
||||
int canvasH = sizes.Max(s => s.h);
|
||||
Bitmap strip = new Bitmap(canvasW, canvasH);
|
||||
|
||||
using (Graphics g = Graphics.FromImage(strip))
|
||||
{
|
||||
g.Clear(Color.Black);
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
int x = i * (cellW + gap) + (cellW - sizes[i].w) / 2;
|
||||
int y = (canvasH - sizes[i].h) / 2;
|
||||
g.DrawImage(source[i], new Rectangle(x, y, sizes[i].w, sizes[i].h));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Image img in source) img.Dispose();
|
||||
return strip;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一行候选:勾选框 + 截图预览条 + 说明文字。
|
||||
/// </summary>
|
||||
private sealed class VideoRow : UserControl
|
||||
{
|
||||
private readonly CheckBox _check = new();
|
||||
private readonly PictureBox _preview = new();
|
||||
private readonly ToolTip _tip = new();
|
||||
|
||||
public VideoCandidate Candidate { get; }
|
||||
public bool IsChecked => _check.Checked;
|
||||
public event EventHandler? CheckChanged;
|
||||
|
||||
public VideoRow(VideoCandidate candidate, string description, bool isChecked)
|
||||
{
|
||||
Candidate = candidate;
|
||||
Height = RowHeight;
|
||||
Margin = new Padding(0);
|
||||
|
||||
TableLayoutPanel layout = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 3,
|
||||
RowCount = 1,
|
||||
};
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 30F));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, PreviewWidth + 8F));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||
|
||||
_check.Dock = DockStyle.None;
|
||||
_check.Anchor = AnchorStyles.Top;
|
||||
_check.Location = new Point(4, 6);
|
||||
_check.Checked = isChecked;
|
||||
_check.CheckedChanged += (_, _) => CheckChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
_preview.Size = new Size(PreviewWidth, PreviewHeight);
|
||||
_preview.SizeMode = PictureBoxSizeMode.Zoom;
|
||||
_preview.BackColor = Color.FromArgb(28, 28, 28);
|
||||
_preview.Image = Placeholder();
|
||||
|
||||
Label text = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(8, 4, 4, 2),
|
||||
Text = description,
|
||||
TextAlign = ContentAlignment.TopLeft,
|
||||
};
|
||||
|
||||
layout.Controls.Add(_check, 0, 0);
|
||||
layout.Controls.Add(_preview, 1, 0);
|
||||
layout.Controls.Add(text, 2, 0);
|
||||
Controls.Add(layout);
|
||||
}
|
||||
|
||||
public void SetResult(Image? image, string? error)
|
||||
{
|
||||
_preview.Image?.Dispose();
|
||||
if (image != null)
|
||||
{
|
||||
_preview.Image = image;
|
||||
_tip.SetToolTip(_preview, string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 把失败原因直接画在预览条上,一眼能看出是超时还是防盗链
|
||||
string reason = string.IsNullOrWhiteSpace(error) ? "截图不可用" : error!;
|
||||
_preview.Image = Placeholder(reason);
|
||||
_tip.SetToolTip(_preview, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>截图还没生成好(或生成失败)时行内显示的占位条。</summary>
|
||||
private static Image Placeholder(string? message = null)
|
||||
{
|
||||
Bitmap bmp = new Bitmap(PreviewWidth, PreviewHeight);
|
||||
using Graphics g = Graphics.FromImage(bmp);
|
||||
g.Clear(Color.FromArgb(28, 28, 28));
|
||||
using (var cellBrush = new SolidBrush(Color.FromArgb(56, 56, 56)))
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
g.FillRectangle(cellBrush, i * 168, 0, 160, PreviewHeight);
|
||||
}
|
||||
}
|
||||
using Font font = new Font("Microsoft YaHei UI", 8F);
|
||||
string text = message ?? "截图生成中…";
|
||||
Color color = message == null ? Color.Gray : Color.FromArgb(230, 140, 90);
|
||||
// 失败原因可能较长,在预览条区域内自动换行
|
||||
var rect = new RectangleF(4, 4, PreviewWidth - 8, PreviewHeight - 8);
|
||||
using var format = new StringFormat { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.LineLimit };
|
||||
g.DrawString(text, font, new SolidBrush(color), rect, format);
|
||||
return bmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
https://www.douyin.com/jingxuan?modal_id=7704345703665422713
|
||||
@@ -0,0 +1 @@
|
||||
{"v": "https://v26-web.douyinvod.com/cde0dd644a3f3fdd8b51ce0d8ac53eea/6aed51d1/video/tos/cn/tos-cn-v-694b40/oUqAC2l4TBfIhPFJ2AcgFisAA41dgvfDqX8DEW/media-video-hvc1/?a=6383&ch=0&cr=8&dr=0&er=1&lr=default&cd=0%7C0%7C0%7C3&cv=1&br=346&bt=346&cs=4&ds=6&mime_type=video_mp4&qs=14&rc=O2RpN2doPDxnNjk1Njs1Z0BpM3l0M3U5cm40ZTYzNDVpM0A2Y2MvNDAuXmAxXmMtNjAxYSNfcGliMmRzX2VhLS1kMi9zcw%3D%3D&btag=c0000e00038000&cquery=100w_100o&dy_q=1793840674&l=20261105090434E2510E54DA3AC7D64180", "a": "https://v26-web.douyinvod.com/e6943c243c43eed40ed2797458968f85/6aed51d1/video/tos/cn/tos-cn-v-694b40/okTEuLCmAKneHtxwXVYxfsIN8ARqAA56GCfeIA/media-audio-und-mp4a/?a=6383&ch=0&cr=8&dr=0&er=1&lr=default&cd=0%7C0%7C0%7C3&cv=1&br=50&bt=50&cs=0&mime_type=video_mp4&qs=1&rc=OTVpOTw6Njo2O2U6ZWQ2NUBpM3l0M3U5cm40ZTYzNDVpM0A1MS80LWBfYy4wLTQxMjQvYSNfcGliMmRzX2VhLS1kMi9zcw%3D%3D&btag=c0000e00038000&cquery=100o_100w&dy_q=1793840674&l=20261105090434E2510E54DA3AC7D64180"}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.100",
|
||||
"rollForward": "latestMajor"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
{
|
||||
"name" : "安全小秘书",
|
||||
"appid" : "__UNI__A3FEFA1",
|
||||
"description" : "本地 SQLite 密码保险箱,图案密码登录",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"name": "安全小秘书",
|
||||
"appid": "__UNI__A3FEFA1",
|
||||
"description": "本地 SQLite 密码保险箱,图案密码登录",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {
|
||||
"sqlite" : {}
|
||||
"modules": {
|
||||
"sqlite": {},
|
||||
"Camera": {}
|
||||
},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
"distribute": {
|
||||
/* android打包配置 */
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
@@ -41,13 +42,17 @@
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
],
|
||||
"abiFilters" : [ "arm64-v8a", "x86" ]
|
||||
"abiFilters": [
|
||||
"arm64-v8a",
|
||||
"x86"
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
"ios": {
|
||||
"dSYMs": false
|
||||
},
|
||||
/* SDK配置 */
|
||||
<<<<<<< HEAD
|
||||
"sdkConfigs" : {
|
||||
"ad" : {},
|
||||
"share" : {
|
||||
@@ -59,31 +64,75 @@
|
||||
},
|
||||
"splashscreen" : {
|
||||
"useOriginalMsgbox" : true
|
||||
=======
|
||||
"sdkConfigs": {
|
||||
"share": {}
|
||||
},
|
||||
"splashscreen": {
|
||||
"useOriginalMsgbox": true
|
||||
},
|
||||
"icons": {
|
||||
"android": {
|
||||
"hdpi": "unpackage/res/icons/72x72.png",
|
||||
"xhdpi": "unpackage/res/icons/96x96.png",
|
||||
"xxhdpi": "unpackage/res/icons/144x144.png",
|
||||
"xxxhdpi": "unpackage/res/icons/192x192.png"
|
||||
},
|
||||
"ios": {
|
||||
"appstore": "unpackage/res/icons/1024x1024.png",
|
||||
"ipad": {
|
||||
"app": "unpackage/res/icons/76x76.png",
|
||||
"app@2x": "unpackage/res/icons/152x152.png",
|
||||
"notification": "unpackage/res/icons/20x20.png",
|
||||
"notification@2x": "unpackage/res/icons/40x40.png",
|
||||
"proapp@2x": "unpackage/res/icons/167x167.png",
|
||||
"settings": "unpackage/res/icons/29x29.png",
|
||||
"settings@2x": "unpackage/res/icons/58x58.png",
|
||||
"spotlight": "unpackage/res/icons/40x40.png",
|
||||
"spotlight@2x": "unpackage/res/icons/80x80.png"
|
||||
},
|
||||
"iphone": {
|
||||
"app@2x": "unpackage/res/icons/120x120.png",
|
||||
"app@3x": "unpackage/res/icons/180x180.png",
|
||||
"notification@2x": "unpackage/res/icons/40x40.png",
|
||||
"notification@3x": "unpackage/res/icons/60x60.png",
|
||||
"settings@2x": "unpackage/res/icons/58x58.png",
|
||||
"settings@3x": "unpackage/res/icons/87x87.png",
|
||||
"spotlight@2x": "unpackage/res/icons/80x80.png",
|
||||
"spotlight@3x": "unpackage/res/icons/120x120.png"
|
||||
}
|
||||
}
|
||||
>>>>>>> 8260c417e21af16b318dd9155b55aa00378c20a5
|
||||
}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
"quickapp" : {},
|
||||
"quickapp": {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"usingComponents" : true
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
"mp-alipay": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
"mp-baidu": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
"mp-toutiao": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
"vueVersion" : "3",
|
||||
"locale" : "zh-Hans"
|
||||
}
|
||||
=======
|
||||
"vueVersion": "3"
|
||||
}
|
||||
>>>>>>> 8260c417e21af16b318dd9155b55aa00378c20a5
|
||||
@@ -219,7 +219,6 @@
|
||||
<text class="file-name" @click="onPickBackup(b)">{{ b.name }}</text>
|
||||
<view class="file-acts">
|
||||
<view class="ico ico-path" @click.stop="onShowPath(b)"></view>
|
||||
<view class="ico ico-share" @click.stop="onShareFile(b)"></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -243,7 +242,7 @@ import { listPasswords, getPassword, savePassword, deletePassword, getSessionKey
|
||||
import { generatePassword } from '@/utils/crypto';
|
||||
import { pickImageAsBase64 } from '@/utils/image';
|
||||
// #ifdef APP-PLUS
|
||||
import { exportBackupFile, shareBackupFile, listBackupFiles, readBackupFile, clearBackupFiles, pickSecFile, readBackupFromUri } from '@/utils/backup';
|
||||
import { exportBackupFile, listBackupFiles, readBackupFile, clearBackupFiles, pickSecFile, readBackupFromUri } from '@/utils/backup';
|
||||
// #endif
|
||||
|
||||
const ADVANCED = '高级';
|
||||
@@ -496,9 +495,8 @@ async function onExport() {
|
||||
}
|
||||
try {
|
||||
const dump = await getExportPlainDump();
|
||||
const url = await exportBackupFile(dump, pwd);
|
||||
uni.showToast({ title: '备份已生成', icon: 'none' });
|
||||
await shareBackupFile(url);
|
||||
await exportBackupFile(dump, pwd);
|
||||
uni.showToast({ title: '备份已生成,可在导入列表查看', icon: 'none' });
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '导出失败', icon: 'none' });
|
||||
}
|
||||
@@ -526,15 +524,15 @@ async function onPickBackup(item: { name: string; localUrl: string }) {
|
||||
}
|
||||
|
||||
async function onPickFromFiles() {
|
||||
const uri = await pickSecFile();
|
||||
if (!uri) {
|
||||
uni.showToast({ title: '未选择文件', icon: 'none' });
|
||||
const picked = await pickSecFile();
|
||||
if (!picked.uri) {
|
||||
uni.showToast({ title: picked.error || '未选择文件', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const pwd = await askPassword('输入所选备份文件的密码');
|
||||
if (pwd === null) return;
|
||||
try {
|
||||
const dump = await readBackupFromUri(uri, pwd);
|
||||
const dump = await readBackupFromUri(picked.uri, pwd);
|
||||
confirmRestore(dump, pwd);
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '恢复失败', icon: 'none' });
|
||||
@@ -586,14 +584,10 @@ function onShowPath(item: { name: string; localUrl: string }) {
|
||||
uni.showModal({ title: '文件路径', content: item.localUrl, showCancel: false, confirmText: '知道了' });
|
||||
}
|
||||
|
||||
async function onShareFile(item: { name: string; localUrl: string }) {
|
||||
await shareBackupFile(item.localUrl);
|
||||
}
|
||||
|
||||
function onClearBackupFiles() {
|
||||
uni.showModal({
|
||||
title: '清空备份文件',
|
||||
content: '将删除本机 _doc/backups 下所有 .sec 备份(已分享到外部的副本不受影响)。确定继续?',
|
||||
content: '将删除本机 _doc/backups 下所有 .sec 备份(已转移到外部的副本不受影响)。确定继续?',
|
||||
confirmText: '清空',
|
||||
confirmColor: '#e53935',
|
||||
success: async (res) => {
|
||||
@@ -1053,7 +1047,6 @@ async function onSave() {
|
||||
.file-acts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.ico-path {
|
||||
@@ -1062,12 +1055,6 @@ async function onSave() {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.ico-share {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -77,23 +77,44 @@ export async function exportBackupFile(
|
||||
w.write(content);
|
||||
}, rej);
|
||||
});
|
||||
return fileEntry.toLocalURL ? fileEntry.toLocalURL() : fileEntry.fullPath;
|
||||
// 返回 _doc 相对路径:可跨设备(真机/模拟器包名不同)
|
||||
return `_doc/backups/${name}`;
|
||||
}
|
||||
|
||||
/** 调起系统分享,把备份文件另存到网盘/微信收藏等(失败静默,不影响已生成文件) */
|
||||
export function shareBackupFile(localUrl: string): Promise<void> {
|
||||
const p = plusAny();
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
p.share.sendWithSystem(
|
||||
{ type: "file", files: [localUrl] },
|
||||
() => resolve(),
|
||||
() => resolve(),
|
||||
);
|
||||
} catch {
|
||||
resolve();
|
||||
/** 用 Java 文件 IO 读取绝对路径文本(不经过 content resolver,真实机型可用) */
|
||||
function readTextByNativePath(p: any, path: string): string {
|
||||
const File = p.android.importClass("java.io.File");
|
||||
const FileInputStream = p.android.importClass("java.io.FileInputStream");
|
||||
const fis = new FileInputStream(new File(path));
|
||||
try {
|
||||
let out = "";
|
||||
let b = fis.read();
|
||||
// 备份文件为 JSON 文本且体积很小,逐字节读取避免跨语言 byte[] 桥接问题
|
||||
while (b !== -1) {
|
||||
out += String.fromCharCode(b);
|
||||
b = fis.read();
|
||||
}
|
||||
});
|
||||
// Latin-1 逐字节拼接后还原 UTF-8 再解码
|
||||
return decodeURIComponent(escape(out));
|
||||
} finally {
|
||||
fis.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过 content resolver 输入流读取文本 */
|
||||
function readTextByStream(p: any, is: any): string {
|
||||
const BufferedReader = p.android.importClass("java.io.BufferedReader");
|
||||
const InputStreamReader = p.android.importClass("java.io.InputStreamReader");
|
||||
const reader = new BufferedReader(new InputStreamReader(is));
|
||||
let out = "";
|
||||
let line = reader.readLine();
|
||||
while (line != null) {
|
||||
out += line;
|
||||
line = reader.readLine();
|
||||
if (line != null) out += "\n";
|
||||
}
|
||||
reader.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 列出 _doc/backups 下的备份文件 */
|
||||
@@ -107,7 +128,8 @@ export async function listBackupFiles(): Promise<BackupItem[]> {
|
||||
.filter((e) => e.name && e.name.endsWith(".sec"))
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
localUrl: e.toLocalURL ? e.toLocalURL() : e.fullPath,
|
||||
// 统一用 _doc 相对路径,跨设备(包名不同)保持稳定
|
||||
localUrl: `_doc/backups/${e.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -169,10 +191,27 @@ export async function clearBackupFiles(): Promise<number> {
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
/** 调起系统文件选择器,返回所选文件的 content URI(取消或失败返回 null) */
|
||||
export function pickSecFile(): Promise<string | null> {
|
||||
/** 调起系统文件选择器,返回所选文件(content URI 或绝对路径);取消/失败返回 null,异常信息在 error */
|
||||
export function pickSecFile(): Promise<{ uri: string | null; error?: string }> {
|
||||
const p = plusAny();
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (r: { uri: string | null; error?: string }) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(r);
|
||||
}
|
||||
};
|
||||
// 优先 HTML5+ 原生选择器:不依赖 activity result 回传,真实机型稳定可用
|
||||
if (typeof p.io.chooseFile === "function") {
|
||||
p.io.chooseFile(
|
||||
(path: string) => done({ uri: path }),
|
||||
(err: any) =>
|
||||
done({ uri: null, error: err?.message || "文件选择失败" }),
|
||||
{ extension: ".sec" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const main = p.android.runtimeMainActivity();
|
||||
const Intent = p.android.importClass("android.content.Intent");
|
||||
@@ -189,9 +228,9 @@ export function pickSecFile(): Promise<string | null> {
|
||||
if (requestCode === REQUEST) {
|
||||
if (resultCode === -1 && data) {
|
||||
const uri = data.getData();
|
||||
resolve(uri ? uri.toString() : null);
|
||||
done({ uri: uri ? uri.toString() : null });
|
||||
} else {
|
||||
resolve(null);
|
||||
done({ uri: null });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -201,13 +240,18 @@ export function pickSecFile(): Promise<string | null> {
|
||||
Intent.createChooser(intent, "选择备份文件"),
|
||||
REQUEST,
|
||||
);
|
||||
} catch {
|
||||
resolve(null);
|
||||
// 新版运行时结果不回传 JS 钩子,超时兜底解除挂起
|
||||
setTimeout(
|
||||
() => done({ uri: null, error: "文件选择器无响应" }),
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
} catch (e: any) {
|
||||
done({ uri: null, error: e?.message || "调起文件选择器失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取 content URI 文本并用密码解密(供“从文件选择 .sec”恢复) */
|
||||
/** 读取所选文件(content URI 或绝对路径)并用密码解密 */
|
||||
export async function readBackupFromUri(
|
||||
uriStr: string,
|
||||
password: string,
|
||||
@@ -215,25 +259,26 @@ export async function readBackupFromUri(
|
||||
const p = plusAny();
|
||||
const content: string = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (!uriStr.startsWith("content://")) {
|
||||
// chooseFile 返回的是本机文件路径:解析为绝对路径后用原生 IO 读取
|
||||
let path = uriStr;
|
||||
try {
|
||||
path = p.io.convertLocalFileSystemURL(uriStr) || uriStr;
|
||||
} catch {
|
||||
// 已是绝对路径,转换失败时沿用原值
|
||||
}
|
||||
path = path.replace(/^file:\/\//, "");
|
||||
const File = p.android.importClass("java.io.File");
|
||||
if (!new File(path).exists()) throw new Error("找不到所选文件");
|
||||
resolve(readTextByNativePath(p, path));
|
||||
return;
|
||||
}
|
||||
const main = p.android.runtimeMainActivity();
|
||||
const Uri = p.android.importClass("android.net.Uri");
|
||||
const uri = Uri.parse(uriStr);
|
||||
const resolver = main.getContentResolver();
|
||||
const is = resolver.openInputStream(uri);
|
||||
const BufferedReader = p.android.importClass("java.io.BufferedReader");
|
||||
const InputStreamReader = p.android.importClass(
|
||||
"java.io.InputStreamReader",
|
||||
);
|
||||
const reader = new BufferedReader(new InputStreamReader(is));
|
||||
let out = "";
|
||||
let line = reader.readLine();
|
||||
while (line != null) {
|
||||
out += line;
|
||||
line = reader.readLine();
|
||||
if (line != null) out += "\n";
|
||||
}
|
||||
reader.close();
|
||||
resolve(out);
|
||||
resolve(readTextByStream(p, is));
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error("读取所选文件失败"));
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |