1、去重
2、分享 3、视频播放 4、批量删除,重新下载 5、自定义标题(仅博主视频) 6、授权页面去掉博主添加,新增关注列表 7、图文视频合成优化 8、其他优化
@@ -6,6 +6,8 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Quartz.Util;
|
||||
using System.Xml.Linq;
|
||||
using static Dm.net.buffer.ByteArrayBuffer;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
@@ -32,7 +34,7 @@ namespace dy.net.Controllers
|
||||
/// <summary>
|
||||
/// 分页查询
|
||||
/// </summary>
|
||||
/// <returns>分页结果(视频列表和总数)</returns>
|
||||
/// <returns>分页结果</returns>
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(
|
||||
PageRequestDto dto)
|
||||
@@ -51,16 +53,26 @@ namespace dy.net.Controllers
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 查询所有用户Cookie
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("list")]
|
||||
public async Task<IActionResult> GetAllList()
|
||||
{
|
||||
var list = await dyCookieService.GetAllOpendAsync();
|
||||
var result = list.Select(x => new { key= x.MyUserId, name= x.UserName });
|
||||
return Ok(new { code = 0, data = result });
|
||||
}
|
||||
/// <summary>
|
||||
/// 新增用户Cookie
|
||||
/// </summary>
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> AddAsync([FromBody] DouyinUserCookie dyUserCookies)
|
||||
public async Task<IActionResult> AddAsync([FromBody] DouyinCookie dyUserCookies)
|
||||
{
|
||||
if(dyUserCookies.UpSecUserIdsJson!=null)
|
||||
{
|
||||
dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject( dyUserCookies.UpSecUserIdsJson);
|
||||
}
|
||||
|
||||
var result = await dyCookieService.Add(dyUserCookies);
|
||||
if (result)
|
||||
{
|
||||
@@ -74,14 +86,9 @@ namespace dy.net.Controllers
|
||||
/// 更新用户Cookie
|
||||
/// </summary>
|
||||
[HttpPost("update")]
|
||||
public async Task<IActionResult> UpdateAsync([FromBody] DouyinUserCookie dyUserCookies)
|
||||
public async Task<IActionResult> UpdateAsync([FromBody] DouyinCookie dyUserCookies)
|
||||
{
|
||||
|
||||
if (dyUserCookies.UpSecUserIdsJson != null )
|
||||
{
|
||||
dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject(dyUserCookies.UpSecUserIdsJson);
|
||||
}
|
||||
|
||||
if (dyUserCookies.Id == "0")
|
||||
{
|
||||
dyUserCookies.Id=IdGener.GetLong().ToString();
|
||||
@@ -144,26 +151,16 @@ namespace dy.net.Controllers
|
||||
public async Task<IActionResult> ExecuteJobNow()
|
||||
{
|
||||
var config = commonService.GetConfig();
|
||||
await quartzJobService.StartJob(config.Cron);
|
||||
if (config!=null)
|
||||
await quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
|
||||
return Ok(new { code = 0 , error = "" });
|
||||
}
|
||||
|
||||
|
||||
private async Task ReStartJob() {
|
||||
var config= commonService.GetConfig();
|
||||
|
||||
//var cookies = await dyCookieService.GetAllCookies();
|
||||
//重置同步状态
|
||||
//foreach (var cookie in cookies)
|
||||
//{
|
||||
// cookie.CollHasSyncd = 0;
|
||||
// cookie.FavHasSyncd = 0;
|
||||
// cookie.UperSyncd = 0;
|
||||
// await dyCookieService.UpdateAsync(cookie);
|
||||
//}
|
||||
|
||||
if (config!=null)
|
||||
quartzJobService.StartJob(config.Cron);
|
||||
quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
|
||||
//避免前端等待
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace dy.net.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class FollowController : ControllerBase
|
||||
{
|
||||
private readonly DouyinFollowService _douyinFollowService;
|
||||
private readonly DouyinQuartzJobService _douyinQuartzJobService;
|
||||
|
||||
public FollowController(DouyinFollowService douyinFollowService, DouyinQuartzJobService douyinQuartzJobService)
|
||||
{
|
||||
this._douyinFollowService = douyinFollowService;
|
||||
_douyinQuartzJobService = douyinQuartzJobService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询
|
||||
/// </summary>
|
||||
/// <returns>分页结果</returns>
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(
|
||||
FollowRequestDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.MySelfId))
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new { }
|
||||
});
|
||||
}
|
||||
var (list, totalCount) = await _douyinFollowService.GetPagedAsync(dto);
|
||||
return Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
{
|
||||
data = list,
|
||||
total = totalCount,
|
||||
pageIndex = dto.PageIndex,
|
||||
pageSize = dto.PageSize
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
/// <summary>
|
||||
/// 重新同步-单次
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("sync")]
|
||||
public async Task<IActionResult> SyncFollowList()
|
||||
{
|
||||
//后台异步
|
||||
_douyinQuartzJobService.StartFollowJobOnceAsync();
|
||||
await Task.Delay(1000);
|
||||
return Ok(new { code = 0 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改关注同步状态
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("openOrCloseSync")]
|
||||
public async Task<IActionResult> OpenOrCloseSync(FollowUpdateDto dto)
|
||||
{
|
||||
|
||||
if (dto.OpenSync)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dto.SavePath))
|
||||
{
|
||||
if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(dto.SavePath))
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
code = -1,
|
||||
msg = "请输入有效文件夹名称(字母数字中文简体)"
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.SavePath.Length > 10)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
code = -1,
|
||||
msg = "请输入有效文件夹名称(最长10)"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result= await _douyinFollowService.OpenOrCloseSync(dto);
|
||||
return Ok(new
|
||||
{
|
||||
code = result ? 0 : -1,
|
||||
data = result
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改关注全量同步状态
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("openOrCloseFullSync")]
|
||||
public async Task<IActionResult> OpenOrCloseFullSync(FollowUpdateDto dto)
|
||||
{
|
||||
|
||||
if (dto.FullSync)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dto.SavePath))
|
||||
{
|
||||
if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(dto.SavePath))
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
code = -1,
|
||||
msg = "请输入有效文件夹名称(字母数字中文简体)"
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.SavePath.Length > 10)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
code = -1,
|
||||
msg = "请输入有效文件夹名称(最长10)"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = await _douyinFollowService.OpenOrCloseFullSync(dto);
|
||||
return Ok(new
|
||||
{
|
||||
code = result ? 0 : -1,
|
||||
data = result
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,10 +15,12 @@ namespace dy.net.Controllers
|
||||
public class VideoController : ControllerBase
|
||||
{
|
||||
private readonly DouyinVideoService dyCollectVideoService;
|
||||
private readonly DouyinQuartzJobService douyinQuartzJobService;
|
||||
|
||||
public VideoController(DouyinVideoService dyCollectVideoService)
|
||||
public VideoController(DouyinVideoService dyCollectVideoService,DouyinQuartzJobService douyinQuartzJobService)
|
||||
{
|
||||
this.dyCollectVideoService = dyCollectVideoService;
|
||||
this.douyinQuartzJobService = douyinQuartzJobService;
|
||||
}
|
||||
/// <summary>
|
||||
/// 分页查询收藏视频
|
||||
@@ -26,7 +29,7 @@ namespace dy.net.Controllers
|
||||
[HttpPost("paged")]
|
||||
public async Task<IActionResult> GetPagedAsync(DouyinVideoPageRequestDto dto)
|
||||
{
|
||||
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Author,dto.ViedoType,dto.Dates);
|
||||
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto);
|
||||
return Ok(new
|
||||
{
|
||||
code = 0,
|
||||
@@ -67,48 +70,87 @@ namespace dy.net.Controllers
|
||||
{
|
||||
var viedo = await dyCollectVideoService.GetById(vid);
|
||||
|
||||
if(viedo == null)
|
||||
if (viedo == null)
|
||||
{
|
||||
return NotFound($"视频不存在:{vid}");
|
||||
}
|
||||
return PlayViedo(viedo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, $"视频加载失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult PlayViedo(DouyinVideo viedo)
|
||||
{
|
||||
|
||||
// 1. 拼接完整物理路径(配置路径 + 文件名)
|
||||
string videoFullPath = viedo.VideoSavePath;
|
||||
|
||||
// 2. 验证文件是否存在
|
||||
if (!System.IO.File.Exists(videoFullPath))
|
||||
{
|
||||
return NotFound($"视频文件不存在:{videoFullPath}");
|
||||
}
|
||||
|
||||
// 3. 获取文件信息(大小、类型)
|
||||
var fileInfo = new FileInfo(videoFullPath);
|
||||
long fileSize = fileInfo.Length;
|
||||
string contentType = GetContentType(videoFullPath); // 自动识别视频 MIME 类型
|
||||
|
||||
// 4. 处理分片请求(前端视频标签自动发起,支持断点续传)
|
||||
if (Request.Headers.ContainsKey("Range") && long.TryParse(Request.Headers.Range.ToString().Split('=')[1].Split('-')[0], out long start))
|
||||
{
|
||||
// 分片起始位置(前端请求的起始字节)
|
||||
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1); // 每片 2MB(可调整)
|
||||
long chunkSize = end - start + 1;
|
||||
|
||||
// 5. 设置分片响应头
|
||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
|
||||
Response.Headers.Add("Accept-Ranges", "bytes");
|
||||
Response.Headers.Add("Content-Length", chunkSize.ToString());
|
||||
|
||||
// 6. 读取分片并返回流
|
||||
var stream = new FileStream(videoFullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 完整文件请求(兼容旧浏览器)
|
||||
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 播放视频
|
||||
/// </summary>
|
||||
/// <param name="vid"></param>
|
||||
/// <param name="k"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("/share/{vid}/{k}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Share([FromRoute] string vid, [FromRoute] string k)
|
||||
{
|
||||
try
|
||||
{
|
||||
var viedo = await dyCollectVideoService.GetById(vid);
|
||||
|
||||
if (viedo == null)
|
||||
{
|
||||
return NotFound($"视频不存在:{vid}");
|
||||
}
|
||||
|
||||
// 1. 拼接完整物理路径(配置路径 + 文件名)
|
||||
string videoFullPath = viedo.VideoSavePath;
|
||||
|
||||
// 2. 验证文件是否存在
|
||||
if (!System.IO.File.Exists(videoFullPath))
|
||||
var expectedKey = (viedo.FileHash + viedo.AuthorId).Md5();
|
||||
if (expectedKey != k)
|
||||
{
|
||||
return NotFound($"视频文件不存在:{videoFullPath}");
|
||||
return NotFound($"视频地址无效");
|
||||
}
|
||||
|
||||
// 3. 获取文件信息(大小、类型)
|
||||
var fileInfo = new FileInfo(videoFullPath);
|
||||
long fileSize = fileInfo.Length;
|
||||
string contentType = GetContentType(videoFullPath); // 自动识别视频 MIME 类型
|
||||
|
||||
// 4. 处理分片请求(前端视频标签自动发起,支持断点续传)
|
||||
if (Request.Headers.ContainsKey("Range") && long.TryParse(Request.Headers.Range.ToString().Split('=')[1].Split('-')[0], out long start))
|
||||
{
|
||||
// 分片起始位置(前端请求的起始字节)
|
||||
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1); // 每片 2MB(可调整)
|
||||
long chunkSize = end - start + 1;
|
||||
|
||||
// 5. 设置分片响应头
|
||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
|
||||
Response.Headers.Add("Accept-Ranges", "bytes");
|
||||
Response.Headers.Add("Content-Length", chunkSize.ToString());
|
||||
|
||||
// 6. 读取分片并返回流
|
||||
var stream = new FileStream(videoFullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 完整文件请求(兼容旧浏览器)
|
||||
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
return PlayViedo(viedo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -133,6 +175,32 @@ namespace dy.net.Controllers
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新下载
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
|
||||
[HttpPost("redown")]
|
||||
public async Task<IActionResult> ReDownload(ReDownViedoDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
{
|
||||
return Ok(new { code = -1, data = false });
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await dyCollectVideoService.ReDownloadViedoAsync(dto);
|
||||
if (result)
|
||||
{
|
||||
//douyinQuartzJobService.StartReDownJobOnceAsync();...无法实现。。逆向失败
|
||||
return Ok(new { code = 0, data = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new { code = -1, data = false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace dy.net
|
||||
private static bool downImageVideo = false;
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
//Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
|
||||
// 初始化编码提供器
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
@@ -53,18 +53,16 @@ namespace dy.net
|
||||
|
||||
// 构建应用
|
||||
var app = builder.Build();
|
||||
|
||||
Log.Debug("ffmpeg=" + downImgConfig);
|
||||
if (downImageVideo)
|
||||
Log.Debug("ffmpeg is on");
|
||||
|
||||
// 配置中间件
|
||||
ConfigureMiddleware(app, builder.Environment);
|
||||
|
||||
// 初始化应用服务
|
||||
InitApplicationServices(app);
|
||||
InitApplicationServices(app, isDevelopment);
|
||||
|
||||
Serilog.Log.Debug("dy.sync service is starting...");
|
||||
|
||||
Log.Debug("默认设置-博主作品-同步全部-配置为关闭,(可到授权界面开启,不建议全量同步)");
|
||||
Log.Debug("dy.sync service is started successfully");
|
||||
Console.WriteLine("------------------------------------------------------------------------");
|
||||
Console.WriteLine(@" __ \\ \ / ___|\ \ / \ | ___|
|
||||
@@ -73,7 +71,7 @@ namespace dy.net
|
||||
____/ _|_)_____/ _| _| \_|\____|
|
||||
|
||||
");
|
||||
Console.ResetColor();
|
||||
//Console.ResetColor();
|
||||
app.Run();
|
||||
}
|
||||
|
||||
@@ -125,13 +123,15 @@ ____/ _|_)_____/ _| _| \_|\____|
|
||||
// 仓储和服务注册
|
||||
services.AddServicesFromNamespace("dy.net.repository")
|
||||
.AddServicesFromNamespace("dy.net.service");
|
||||
//下载图片合成视频-需要ffmpeg支持,镜像会很大。
|
||||
if (downImageVideo)
|
||||
{
|
||||
//根据配置动态加载dy.image程序集
|
||||
Assembly assembly = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "dy.image.dll"));
|
||||
services.AddServicesFromNamespace("dy.image", assembly);
|
||||
}
|
||||
|
||||
services.AddSingleton<FFmpegHelper>();
|
||||
////下载图片合成视频-需要ffmpeg支持,镜像会很大。
|
||||
//if (downImageVideo)
|
||||
//{
|
||||
// //根据配置动态加载dy.image程序集
|
||||
// Assembly assembly = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "dy.image.dll"));
|
||||
// services.AddServicesFromNamespace("dy.image", assembly);
|
||||
//}
|
||||
// SPA静态文件支持
|
||||
services.AddSpaStaticFiles(options => options.RootPath = SpaRootPath);
|
||||
|
||||
@@ -189,7 +189,7 @@ ____/ _|_)_____/ _| _| \_|\____|
|
||||
/// <summary>
|
||||
/// 初始化应用服务数据
|
||||
/// </summary>
|
||||
private static void InitApplicationServices(WebApplication app)
|
||||
private static void InitApplicationServices(WebApplication app,bool isDevelopment)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
@@ -212,9 +212,13 @@ ____/ _|_)_____/ _| _| \_|\____|
|
||||
commonService.UpdateCollectViedoType();
|
||||
// 重置博主作品同步状态为未同步
|
||||
commonService.UpdateAllCookieSyncedToZero();
|
||||
// 启动定时任务
|
||||
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
|
||||
quartzJobService.StartJob(config?.Cron ?? "30");
|
||||
|
||||
if(!isDevelopment)
|
||||
{
|
||||
// 启动定时任务
|
||||
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
|
||||
quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString());
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -4,8 +4,37 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
|
||||
<History>True|2025-11-26T15:48:02.3957186Z||;True|2025-11-26T23:43:06.8154188+08:00||;False|2025-11-26T23:42:05.9191485+08:00||;True|2025-11-26T23:30:11.1295861+08:00||;True|2025-11-26T00:00:42.1507258+08:00||;True|2025-11-26T00:00:17.0107229+08:00||;True|2025-11-25T23:42:07.4349629+08:00||;False|2025-11-25T23:41:56.9328658+08:00||;True|2025-11-25T23:19:32.5262917+08:00||;True|2025-11-25T14:08:53.3850967+08:00||;True|2025-11-24T23:53:54.4283003+08:00||;True|2025-11-24T23:41:15.8248332+08:00||;True|2025-11-24T23:33:57.1844427+08:00||;True|2025-11-24T23:31:54.9228836+08:00||;True|2025-11-24T23:24:15.8681502+08:00||;True|2025-11-24T23:22:57.9046229+08:00||;True|2025-11-24T23:18:04.1131647+08:00||;True|2025-11-24T22:47:54.1336448+08:00||;True|2025-11-24T22:47:18.3613833+08:00||;True|2025-11-24T08:17:50.3994607+08:00||;True|2025-11-24T08:07:00.9951205+08:00||;True|2025-11-23T23:34:50.0030826+08:00||;True|2025-11-23T23:32:36.7452616+08:00||;True|2025-11-22T23:18:56.3202345+08:00||;True|2025-11-22T22:52:51.7203302+08:00||;True|2025-11-22T22:52:42.9620946+08:00||;True|2025-11-22T22:52:09.8257640+08:00||;True|2025-11-22T22:39:31.5894141+08:00||;True|2025-11-22T22:31:11.0704815+08:00||;True|2025-11-22T22:20:36.4579131+08:00||;True|2025-11-22T22:19:10.2281364+08:00||;True|2025-11-22T19:34:45.9336901+08:00||;True|2025-11-12T23:06:38.7834109+08:00||;False|2025-11-12T23:01:02.2269478+08:00||;True|2025-10-22T22:08:11.6423148+08:00||;True|2025-10-22T21:54:24.7180547+08:00||;True|2025-10-22T21:40:03.5194315+08:00||;True|2025-10-22T21:28:28.8962766+08:00||;True|2025-10-22T21:22:47.9631689+08:00||;True|2025-10-22T21:18:24.5274318+08:00||;True|2025-10-22T21:14:51.6386326+08:00||;False|2025-10-22T21:14:07.6282769+08:00||;True|2025-10-22T21:03:48.9892860+08:00||;True|2025-10-22T21:00:56.3617243+08:00||;False|2025-10-22T21:00:30.5472941+08:00||;True|2025-10-22T20:51:29.6155916+08:00||;False|2025-10-22T20:50:47.1882956+08:00||;True|2025-10-22T15:01:04.1668366+08:00||;True|2025-10-22T14:49:43.6340569+08:00||;True|2025-10-22T14:38:39.4685603+08:00||;True|2025-10-21T18:35:28.8392541+08:00||;True|2025-10-20T10:31:05.5865212+08:00||;True|2025-10-20T10:20:41.5717101+08:00||;True|2025-10-19T10:08:33.6669332+08:00||;False|2025-10-19T10:07:22.3337545+08:00||;False|2025-10-19T10:05:22.9484805+08:00||;True|2025-10-10T16:54:27.1472888+08:00||;False|2025-10-10T16:53:44.1700030+08:00||;False|2025-10-10T16:52:48.1740453+08:00||;False|2025-10-10T16:51:21.5067253+08:00||;False|2025-10-10T16:50:19.2140597+08:00||;False|2025-10-10T16:49:21.2213290+08:00||;False|2025-10-10T16:48:47.7229948+08:00||;False|2025-10-10T16:48:15.1258700+08:00||;True|2025-09-30T13:29:42.2354235+08:00||;True|2025-09-25T11:44:52.6858389+08:00||;True|2025-09-25T11:08:27.8810109+08:00||;True|2025-09-25T09:28:50.2563374+08:00||;True|2025-09-24T16:14:02.3072184+08:00||;True|2025-09-23T22:38:52.4414757+08:00||;True|2025-09-23T22:19:07.1006356+08:00||;True|2025-09-23T22:18:01.5945225+08:00||;True|2025-09-23T22:06:15.6293613+08:00||;True|2025-09-23T21:53:14.2977444+08:00||;True|2025-09-23T21:46:55.2322411+08:00||;False|2025-09-23T21:45:23.7858854+08:00||;False|2025-09-23T21:03:51.8325689+08:00||;True|2025-09-23T10:03:32.2251920+08:00||;False|2025-09-23T09:38:08.2372201+08:00||;False|2025-09-23T09:37:49.9390545+08:00||;True|2024-03-11T21:31:45.8102398+08:00||;True|2024-03-11T07:26:24.7660541+08:00||;True|2024-03-08T22:08:40.0154831+08:00||;True|2024-03-03T10:14:36.8109114+08:00||;True|2024-03-02T18:44:57.3288537+08:00||;True|2024-01-24T17:51:37.9164415+08:00||;True|2024-01-24T16:36:50.5612157+08:00||;True|2024-01-24T15:51:35.7556653+08:00||;True|2024-01-17T23:40:40.7526618+08:00||;True|2024-01-17T23:36:10.3692844+08:00||;True|2024-01-17T23:22:03.2378834+08:00||;True|2024-01-03T11:35:44.7118292+08:00||;True|2024-01-03T11:11:23.4270453+08:00||;True|2024-01-03T11:04:35.2081526+08:00||;True|2024-01-03T10:57:03.7053107+08:00||;True|2024-01-03T10:51:50.7463989+08:00||;False|2024-01-03T10:50:24.9775312+08:00||;True|2024-01-03T10:47:30.1128183+08:00||;True|2024-01-03T10:42:55.8640657+08:00||;True|2024-01-03T09:24:24.3436056+08:00||;</History>
|
||||
<_PublishTargetUrl>bin\Release\net6.0\publish\</_PublishTargetUrl>
|
||||
<History>True|2025-11-27T05:37:08.6884390Z||;False|2025-11-27T13:36:54.2595622+08:00||;True|2025-11-27T11:03:33.8401644+08:00||;False|2025-11-27T11:02:48.8942827+08:00||;True|2025-11-26T23:48:02.3957186+08:00||;True|2025-11-26T23:43:06.8154188+08:00||;False|2025-11-26T23:42:05.9191485+08:00||;True|2025-11-26T23:30:11.1295861+08:00||;True|2025-11-26T00:00:42.1507258+08:00||;True|2025-11-26T00:00:17.0107229+08:00||;True|2025-11-25T23:42:07.4349629+08:00||;False|2025-11-25T23:41:56.9328658+08:00||;True|2025-11-25T23:19:32.5262917+08:00||;True|2025-11-25T14:08:53.3850967+08:00||;True|2025-11-24T23:53:54.4283003+08:00||;True|2025-11-24T23:41:15.8248332+08:00||;True|2025-11-24T23:33:57.1844427+08:00||;True|2025-11-24T23:31:54.9228836+08:00||;True|2025-11-24T23:24:15.8681502+08:00||;True|2025-11-24T23:22:57.9046229+08:00||;True|2025-11-24T23:18:04.1131647+08:00||;True|2025-11-24T22:47:54.1336448+08:00||;True|2025-11-24T22:47:18.3613833+08:00||;True|2025-11-24T08:17:50.3994607+08:00||;True|2025-11-24T08:07:00.9951205+08:00||;True|2025-11-23T23:34:50.0030826+08:00||;True|2025-11-23T23:32:36.7452616+08:00||;True|2025-11-22T23:18:56.3202345+08:00||;True|2025-11-22T22:52:51.7203302+08:00||;True|2025-11-22T22:52:42.9620946+08:00||;True|2025-11-22T22:52:09.8257640+08:00||;True|2025-11-22T22:39:31.5894141+08:00||;True|2025-11-22T22:31:11.0704815+08:00||;True|2025-11-22T22:20:36.4579131+08:00||;True|2025-11-22T22:19:10.2281364+08:00||;True|2025-11-22T19:34:45.9336901+08:00||;True|2025-11-12T23:06:38.7834109+08:00||;False|2025-11-12T23:01:02.2269478+08:00||;True|2025-10-22T22:08:11.6423148+08:00||;True|2025-10-22T21:54:24.7180547+08:00||;True|2025-10-22T21:40:03.5194315+08:00||;True|2025-10-22T21:28:28.8962766+08:00||;True|2025-10-22T21:22:47.9631689+08:00||;True|2025-10-22T21:18:24.5274318+08:00||;True|2025-10-22T21:14:51.6386326+08:00||;False|2025-10-22T21:14:07.6282769+08:00||;True|2025-10-22T21:03:48.9892860+08:00||;True|2025-10-22T21:00:56.3617243+08:00||;False|2025-10-22T21:00:30.5472941+08:00||;True|2025-10-22T20:51:29.6155916+08:00||;False|2025-10-22T20:50:47.1882956+08:00||;True|2025-10-22T15:01:04.1668366+08:00||;True|2025-10-22T14:49:43.6340569+08:00||;True|2025-10-22T14:38:39.4685603+08:00||;True|2025-10-21T18:35:28.8392541+08:00||;True|2025-10-20T10:31:05.5865212+08:00||;True|2025-10-20T10:20:41.5717101+08:00||;True|2025-10-19T10:08:33.6669332+08:00||;False|2025-10-19T10:07:22.3337545+08:00||;False|2025-10-19T10:05:22.9484805+08:00||;True|2025-10-10T16:54:27.1472888+08:00||;False|2025-10-10T16:53:44.1700030+08:00||;False|2025-10-10T16:52:48.1740453+08:00||;False|2025-10-10T16:51:21.5067253+08:00||;False|2025-10-10T16:50:19.2140597+08:00||;False|2025-10-10T16:49:21.2213290+08:00||;False|2025-10-10T16:48:47.7229948+08:00||;False|2025-10-10T16:48:15.1258700+08:00||;True|2025-09-30T13:29:42.2354235+08:00||;True|2025-09-25T11:44:52.6858389+08:00||;True|2025-09-25T11:08:27.8810109+08:00||;True|2025-09-25T09:28:50.2563374+08:00||;True|2025-09-24T16:14:02.3072184+08:00||;True|2025-09-23T22:38:52.4414757+08:00||;True|2025-09-23T22:19:07.1006356+08:00||;True|2025-09-23T22:18:01.5945225+08:00||;True|2025-09-23T22:06:15.6293613+08:00||;True|2025-09-23T21:53:14.2977444+08:00||;True|2025-09-23T21:46:55.2322411+08:00||;False|2025-09-23T21:45:23.7858854+08:00||;False|2025-09-23T21:03:51.8325689+08:00||;True|2025-09-23T10:03:32.2251920+08:00||;False|2025-09-23T09:38:08.2372201+08:00||;False|2025-09-23T09:37:49.9390545+08:00||;True|2024-03-11T21:31:45.8102398+08:00||;True|2024-03-11T07:26:24.7660541+08:00||;True|2024-03-08T22:08:40.0154831+08:00||;True|2024-03-03T10:14:36.8109114+08:00||;True|2024-03-02T18:44:57.3288537+08:00||;True|2024-01-24T17:51:37.9164415+08:00||;True|2024-01-24T16:36:50.5612157+08:00||;True|2024-01-24T15:51:35.7556653+08:00||;True|2024-01-17T23:40:40.7526618+08:00||;True|2024-01-17T23:36:10.3692844+08:00||;True|2024-01-17T23:22:03.2378834+08:00||;True|2024-01-03T11:35:44.7118292+08:00||;True|2024-01-03T11:11:23.4270453+08:00||;True|2024-01-03T11:04:35.2081526+08:00||;True|2024-01-03T10:57:03.7053107+08:00||;True|2024-01-03T10:51:50.7463989+08:00||;</History>
|
||||
<LastFailureDetails />
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<File Include="dto/AdminUserDto.cs">
|
||||
<publishTime>11/12/2025 21:56:08</publishTime>
|
||||
</File>
|
||||
<File Include="dto/DouyinUpSecUserIdDto.cs">
|
||||
<publishTime>11/22/2025 17:11:30</publishTime>
|
||||
</File>
|
||||
<File Include="dto/DouyinVideoInfo.cs">
|
||||
<publishTime>11/22/2025 17:11:30</publishTime>
|
||||
</File>
|
||||
<File Include="dto/DouyinVideoNfo.cs">
|
||||
<publishTime>11/22/2025 17:00:49</publishTime>
|
||||
</File>
|
||||
<File Include="dto/DouyinVideoPageRequestDto.cs">
|
||||
<publishTime>11/22/2025 17:11:19</publishTime>
|
||||
</File>
|
||||
<File Include="dto/DouyinVideoRequestDto.cs">
|
||||
<publishTime>11/22/2025 17:11:30</publishTime>
|
||||
</File>
|
||||
<File Include="dto/InitSystemInfo.cs">
|
||||
<publishTime>11/12/2025 21:56:08</publishTime>
|
||||
</File>
|
||||
<File Include="dto/VideoStaticsDto.cs">
|
||||
<publishTime>11/23/2025 22:21:42</publishTime>
|
||||
</File>
|
||||
<File Include="dto/VideoTypeEnum.cs">
|
||||
<publishTime>11/23/2025 19:09:25</publishTime>
|
||||
</File>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,7 +2,7 @@
|
||||
"profiles": {
|
||||
"dy.net": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
//"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
|
||||
@@ -95,7 +95,7 @@ Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,
|
||||
| `full_latest` | x86_64 (amd64) | **完整版**- 包含全部核心功能- **内置 FFmpeg**- 支持图文视频下载与合成 |
|
||||
| `arm_latest` | ARM64 | **ARM 标准版**- 核心功能(与 `latest` 一致) |
|
||||
| `full_arm_latest` | ARM64 | **ARM 完整版**- 完整功能(与 `full_latest` 一致) |
|
||||
| `beta_1.0` | x86_64 (amd64) | **测试版 v1.0**- 测试版 |
|
||||
| `beta_1.2` | x86_64 (amd64) | **测试版 v1.0**- 测试版 |
|
||||
|
||||
### 构建命令示例
|
||||
|
||||
@@ -186,7 +186,7 @@ services:
|
||||
|
||||
7. ✅ 将网站名称改成 "抖小云" 灵感来源于哪吒电影 驮门的那个小云云
|
||||
|
||||
8. ⭕️ <span style="color: #999;">Cookie 过期提醒(或者看看能不能实现扫码登录自动获取 Cookie)</span>
|
||||
8. ⭕️ <span style="color: #999;">Cookie 过期提醒</span>
|
||||
|
||||
9. ⭕️ <span style="color: #999;">重复视频去重(一个视频同时属于收藏视频、喜欢的视频或指定的博主作品)</span>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>打工人必备3个邪修办公工具,早用早下班!!#办公技巧 #干货分享 #文员 #找工作 #vlog日常</title>
|
||||
<author>丸子不是圆子</author>
|
||||
<releasedate>2025-11-14</releasedate>
|
||||
<genre>个人管理</genre>
|
||||
<genre>职场技能</genre>
|
||||
<genre>办公效率</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>丸子不是圆子</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/coll2\author\3833318009221843.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>冬天穿羽绒服就扎这三款丸子头 #简单盘发教程 #扎发教程 #简单好看发型 #日常编发 #盘发</title>
|
||||
<author>爱编发的小童</author>
|
||||
<releasedate>2025-11-19</releasedate>
|
||||
<genre>时尚</genre>
|
||||
<genre>彩妆</genre>
|
||||
<genre>发型</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>爱编发的小童</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/coll2\author\2253324655537024.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>“最好的医生”其实就在你身边,医生眼里的“最好”:白开水、阳光、徒步……</title>
|
||||
<author>新华社</author>
|
||||
<releasedate>2025-11-07</releasedate>
|
||||
<genre>时政社会</genre>
|
||||
<genre>社会新闻</genre>
|
||||
<genre>民生</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>新华社</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/coll2\author\93288642596.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>披星戴月的日子值吗?男孩分享爸爸送给自己的一段话,自律的苦轻如鸿毛,后悔的痛重过千斤。</title>
|
||||
<author>新华社</author>
|
||||
<releasedate>2025-11-22</releasedate>
|
||||
<genre>时政社会</genre>
|
||||
<genre>社会新闻</genre>
|
||||
<genre>民生</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>新华社</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/coll2\author\93288642596.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>每天练一题,让孩子爱上数学!小学数学奥数竖式谜口诀解题技巧 #小学数学 #小学奥数 #小学数学解题技巧 #小学数学思维 #小学数学题讲解视频</title>
|
||||
<author>懒熊数学思维(小学奥数)</author>
|
||||
<releasedate>2025-11-25</releasedate>
|
||||
<genre>校园教育</genre>
|
||||
<genre>K12教育</genre>
|
||||
<genre>学科教育</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>懒熊数学思维(小学奥数)</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/coll2\author\3432007521806423.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>万万没想到,文件传输助手竟如此全能 #文件传输助手 #冷知识</title>
|
||||
<author>主持人阿喆</author>
|
||||
<releasedate>2025-08-01</releasedate>
|
||||
<genre>科普</genre>
|
||||
<genre>冷知识</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>主持人阿喆</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/fav2\author\75856936333.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>幸福的愿望 想念变成空气在叹息</title>
|
||||
<author>小思宇</author>
|
||||
<releasedate>2025-06-19</releasedate>
|
||||
<genre>随拍</genre>
|
||||
<genre>人物随拍</genre>
|
||||
<genre>人物图片轮播</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>小思宇</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/fav2\author\2664814926116075.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<movie>
|
||||
<title>放假领她去帮我哥打包,体验生活的不容易。#生产车间现场 #拼命努力的挣钱 #工作实拍</title>
|
||||
<author>郭小胖</author>
|
||||
<releasedate>2024-07-20</releasedate>
|
||||
<genre>随拍</genre>
|
||||
<genre>生活记录</genre>
|
||||
<genre>日常vlog</genre>
|
||||
<actors>
|
||||
<actor>
|
||||
<name>郭小胖</name>
|
||||
<role>主演</role>
|
||||
<thumb>app/fav2\author\66899755406.jpg</thumb>
|
||||
</actor>
|
||||
</actors>
|
||||
<thumb aspect="poster">poster.jpg</thumb>
|
||||
<fanart>
|
||||
<thumb>poster.jpg</thumb>
|
||||
</fanart>
|
||||
</movie>
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -51,7 +51,7 @@
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"babel-register": "^6.26.0",
|
||||
"body-parser": "^1.20.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"gh-pages": "^3.1.0",
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-clean": "^0.4.0",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { getBase64 } from '@/utils/file';
|
||||
import { FormInstance } from 'ant-design-vue';
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { reactive, ref, onMounted, UnwrapRef } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { EditFilled } from '@ant-design/icons-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import type { UnwrapRef } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { StarOutlined, StarFilled, StarTwoTone } from '@ant-design/icons-vue';
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
<template>
|
||||
<div class="dept-user-card-container">
|
||||
<!-- 搜索框 + Tab导航 同一行布局 -->
|
||||
<div class="search-tab-container">
|
||||
<div class="tab-wrapper">
|
||||
<a-tabs v-model:value="activeTabKey" type="line" class="custom-tabs" @change="handleTabChange">
|
||||
<a-tab-pane v-for="tab in tabList" :key="tab.key" :tab="`${tab.name}(${tab.total || 0})`" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
<div class="search-area">
|
||||
<!-- 搜索按钮 -->
|
||||
<a-button type="default" class="search-btn" @click="toggleSearchInput">
|
||||
<CloseOutlined v-if="searchInputVisible" />
|
||||
<SearchOutlined v-else />
|
||||
</a-button>
|
||||
<transition name="search-input-fade">
|
||||
<div v-if="searchInputVisible" class="search-input-wrapper">
|
||||
<a-input v-model:value="quaryData.followUserName" placeholder="输入关注的用户名,按回车" allow-clear @pressEnter="handleSearch" class="search-input" ref="searchInputRef" />
|
||||
</div>
|
||||
</transition>
|
||||
<!-- 同步按钮(请求期间禁用) -->
|
||||
<!-- <a-button type="primary" class="sync-btn" @click="handleSyncAll" :disabled="isSyncDisabled">
|
||||
<SyncOutlined />
|
||||
<span class="sync-btn-text">同步</span>
|
||||
</a-button> -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片列表区域 -->
|
||||
<div class="card-list-container" @scroll="handleScroll">
|
||||
<a-card v-for="(item, index) in currentTabData" :key="item.id" :data-key="item.id" class="custom-card" :bordered="true" :hoverable="true">
|
||||
<div class="card-inner">
|
||||
<div class="card-switch">
|
||||
<a-switch v-model:checked="item.openSync" @change="(checked) => handleSwitchChange(item, checked)" checked-children="开" un-checked-children="关" />
|
||||
</div>
|
||||
<div class="card-main-content">
|
||||
<div class="avatar-wrapper">
|
||||
<a-avatar shape="circle" size="large" :src="item.uperAvatar" v-if="item.uperAvatar" />
|
||||
<a-avatar shape="circle" size="large" v-else class="avatar-placeholder">
|
||||
{{ item.uperName.charAt(0) }}
|
||||
</a-avatar>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-name">{{ item.uperName }}</div>
|
||||
<!-- 签名多行显示:高度控制 + 溢出截断 + Tooltip气泡 -->
|
||||
<div class="card-desc">
|
||||
<a-tooltip placement="top" :title="item.signature || '无签名'">
|
||||
<span class="signature-text">
|
||||
{{ item.signature || '无签名' }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="card-path-sync-container">
|
||||
<template v-if="!item.openSync">
|
||||
<span class="path-placeholder"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="path-area">
|
||||
<template v-if="item.isEditing">
|
||||
<div class="edit-input-group">
|
||||
<!-- 输入框禁用:item.isSaving 为 true 时 -->
|
||||
<a-input v-model:value="item.savePath" placeholder="请输入文件夹名称" @keypress.enter="() => handleSavePath(item)" maxlength="10" :disabled="item.isSaving" />
|
||||
<!-- 保存按钮禁用:item.isSaving 为 true 时 -->
|
||||
<a-button type="text" class="edit-btn" @click="() => handleSavePath(item)" :disabled="item.isSaving">
|
||||
<SaveOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="path-text" :class="{ 'path-empty': !item.savePath }">
|
||||
{{ item.savePath || '未设置存储文件夹名称' }}
|
||||
</span>
|
||||
<!-- 编辑按钮禁用:item.isSaving 为 true 时 -->
|
||||
<a-button type="text" class="edit-btn" @click="() => handleEditPath(item)" title="编辑文件夹名称" :disabled="item.isSaving">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="sync-switch-wrapper">
|
||||
<span class="sync-label">全量同步</span>
|
||||
<a-switch v-model:checked="item.fullSync" size="small" @change="(checked) => handleSyncChange(item, checked)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<a-spin size="middle" />
|
||||
<span class="loading-text">加载中...</span>
|
||||
</div>
|
||||
<!-- 无更多数据 -->
|
||||
<div v-if="noMoreData && followData.length > 0" class="no-more-container">暂无更多数据</div>
|
||||
<!-- 空状态 -->
|
||||
<div v-if="followData.length === 0 && !loading" class="empty-container">
|
||||
<Empty description="暂无关注用户" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onUnmounted, UnwrapRef, reactive, nextTick } from 'vue';
|
||||
import { message, Spin, Empty, Tooltip } from 'ant-design-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { SyncOutlined, CloseOutlined, SearchOutlined, SaveOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
// Tab列表数据
|
||||
const tabList = ref<Array<{ key: string; name: string; total?: number }>>([]);
|
||||
const activeTabKey = ref('');
|
||||
|
||||
// 关注用户列表数据:新增 isSaving 状态(控制保存期间按钮禁用)
|
||||
const followData = ref<
|
||||
Array<{
|
||||
id: string;
|
||||
mySelfId: string;
|
||||
uperName: string;
|
||||
enterprise: string;
|
||||
signature: string;
|
||||
uperAvatar: string;
|
||||
fullSync: boolean;
|
||||
openSync: boolean;
|
||||
savePath?: string;
|
||||
isEditing: boolean;
|
||||
isSaving?: boolean; // 新增:保存中状态(true=禁用按钮)
|
||||
}>
|
||||
>([]);
|
||||
|
||||
// 状态变量
|
||||
const loading = ref(false);
|
||||
const noMoreData = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const searchInputVisible = ref(false);
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const isSyncDisabled = ref(false); // 同步按钮禁用状态
|
||||
|
||||
// 搜索参数
|
||||
interface QuaryParam {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
followUserName: string | null;
|
||||
mySelfId?: string;
|
||||
}
|
||||
const quaryData: UnwrapRef<QuaryParam> = reactive({
|
||||
pageIndex: 0,
|
||||
pageSize: 20,
|
||||
followUserName: null,
|
||||
mySelfId: '',
|
||||
});
|
||||
|
||||
// 生命周期 - 挂载时初始化
|
||||
onMounted(() => {
|
||||
quaryData.pageIndex = 0;
|
||||
GetCookies();
|
||||
});
|
||||
|
||||
// 生命周期 - 卸载时移除滚动监听
|
||||
onUnmounted(() => {
|
||||
const cardContainer = document.querySelector('.dept-user-card-container .card-list-container');
|
||||
if (cardContainer) {
|
||||
cardContainer.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取Cookie列表(Tab数据)
|
||||
const GetCookies = () => {
|
||||
useApiStore()
|
||||
.CookieList()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
const tabs = (res.data || [{ key: 'tab1', name: '部门一' }]).map((tab) => ({
|
||||
...tab,
|
||||
total: 0,
|
||||
}));
|
||||
tabList.value = tabs;
|
||||
activeTabKey.value = tabList.value[0]?.key || '';
|
||||
quaryData.mySelfId = activeTabKey.value;
|
||||
GetFollows(true);
|
||||
handleTabChange(activeTabKey.value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 获取关注用户列表
|
||||
const GetFollows = (isReset = false) => {
|
||||
if (loading.value || (noMoreData.value && !isReset)) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
useApiStore()
|
||||
.FollowList(quaryData)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
const newData = res.data.data || [];
|
||||
const total = res.data.total || 0;
|
||||
|
||||
// 给新数据初始化 isSaving: false(避免未定义导致的禁用异常)
|
||||
const formattedData = newData.map((item) => ({
|
||||
...item,
|
||||
isSaving: false, // 初始化保存中状态为false
|
||||
isEditing: item.isEditing ?? false, // 兼容后端未返回isEditing的情况
|
||||
}));
|
||||
|
||||
// 更新当前Tab的总数
|
||||
if (isReset) {
|
||||
const tabIndex = tabList.value.findIndex((tab) => tab.key === activeTabKey.value);
|
||||
if (tabIndex !== -1) {
|
||||
tabList.value[tabIndex].total = total;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
if (formattedData.length < quaryData.pageSize) {
|
||||
noMoreData.value = true;
|
||||
hasMore.value = false;
|
||||
} else {
|
||||
noMoreData.value = false;
|
||||
hasMore.value = true;
|
||||
}
|
||||
|
||||
// 合并数据(避免重复)
|
||||
if (isReset) {
|
||||
followData.value = formattedData;
|
||||
} else {
|
||||
const existingKeys = followData.value.map((item) => item.id);
|
||||
const uniqueNewData = formattedData.filter((item) => !existingKeys.includes(item.id));
|
||||
followData.value = [...followData.value, ...uniqueNewData];
|
||||
}
|
||||
|
||||
quaryData.pageIndex += 1;
|
||||
} else {
|
||||
message.error('获取关注用户列表失败');
|
||||
noMoreData.value = true;
|
||||
hasMore.value = false;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('获取关注用户列表异常:', err);
|
||||
message.error('网络异常,请重试');
|
||||
noMoreData.value = true;
|
||||
hasMore.value = false;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// 切换搜索框显示/隐藏
|
||||
const toggleSearchInput = () => {
|
||||
searchInputVisible.value = !searchInputVisible.value;
|
||||
|
||||
if (searchInputVisible.value) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
} else {
|
||||
// 隐藏时清空搜索条件并重新加载
|
||||
if (quaryData.followUserName) {
|
||||
quaryData.followUserName = null;
|
||||
quaryData.pageIndex = 0;
|
||||
GetFollows(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const handleSearch = () => {
|
||||
quaryData.pageIndex = 0;
|
||||
noMoreData.value = false;
|
||||
hasMore.value = true;
|
||||
GetFollows(true);
|
||||
};
|
||||
|
||||
// Tab切换
|
||||
const handleTabChange = (key: string) => {
|
||||
activeTabKey.value = key;
|
||||
quaryData.mySelfId = key;
|
||||
quaryData.pageIndex = 0;
|
||||
noMoreData.value = false;
|
||||
hasMore.value = true;
|
||||
searchInputVisible.value = false;
|
||||
quaryData.followUserName = null;
|
||||
GetFollows(true);
|
||||
};
|
||||
|
||||
// 计算当前Tab的用户数据
|
||||
const currentTabData = computed(() => {
|
||||
return followData.value.filter((item) => item.mySelfId === activeTabKey.value);
|
||||
});
|
||||
|
||||
// 滚动加载更多
|
||||
const handleScroll = () => {
|
||||
const cardContainer = document.querySelector('.dept-user-card-container .card-list-container');
|
||||
if (!cardContainer || loading.value || !hasMore.value || noMoreData.value) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = cardContainer as HTMLDivElement;
|
||||
|
||||
// 滚动到底部100px内时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
GetFollows(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 开关状态变更(启用/禁用用户)
|
||||
const handleSwitchChange = (item, checked) => {
|
||||
item.openSync = checked;
|
||||
if (!checked) item.isEditing = false; // 禁用时关闭编辑状态
|
||||
if (!checked) {
|
||||
uploadSyncStatus(item);
|
||||
} else {
|
||||
if (item.savePath.length > 0) {
|
||||
uploadSyncStatus(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 全量同步开关变更
|
||||
const handleSyncChange = (item, checked) => {
|
||||
item.fullSync = checked;
|
||||
uploadFullSyncStatus(item);
|
||||
};
|
||||
|
||||
// 编辑存储路径:增加防重复编辑(保存中不可编辑)
|
||||
const handleEditPath = (item) => {
|
||||
if (item.isSaving) return; // 正在保存时,禁止点击编辑
|
||||
item.isEditing = true;
|
||||
// 延迟聚焦输入框(确保DOM已更新)
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector(
|
||||
`.dept-user-card-container .custom-card[data-key="${item.id}"] .edit-input-group .ant-input`
|
||||
) as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 保存存储路径:增加按钮禁用逻辑
|
||||
const handleSavePath = (item) => {
|
||||
// 防止重复提交:正在保存时直接返回
|
||||
if (item.isSaving) return;
|
||||
uploadSyncStatus(item);
|
||||
};
|
||||
const uploadSyncStatus = (item) => {
|
||||
item.isSaving = true;
|
||||
useApiStore()
|
||||
.OpenOrCloseSync({
|
||||
Id: item.id,
|
||||
OpenSync: item.openSync,
|
||||
FullSync: item.fullSync,
|
||||
SavePath: item.savePath, // 保持原有逻辑:提交 savePath
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
message.success(`保存成功,将在下次任务执行时生效`);
|
||||
item.isEditing = false; // 保存成功后关闭编辑状态
|
||||
} else {
|
||||
message.error('保存失败' + (res.msg || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('保存失败', err);
|
||||
message.error('保存失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
// 接口响应结束:解除禁用(无论成功失败)
|
||||
item.isSaving = false;
|
||||
});
|
||||
};
|
||||
|
||||
//全量同步开关
|
||||
const uploadFullSyncStatus = (item) => {
|
||||
item.isSaving = true;
|
||||
useApiStore()
|
||||
.OpenOrCloseFullSync({
|
||||
Id: item.id,
|
||||
OpenSync: item.openSync,
|
||||
FullSync: item.fullSync,
|
||||
SavePath: item.savePath, // 保持原有逻辑:提交 savePath
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
message.success(`保存成功,将在下次任务执行时生效`);
|
||||
item.isEditing = false; // 保存成功后关闭编辑状态
|
||||
} else {
|
||||
message.error('保存失败' + (res.msg || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('保存失败', err);
|
||||
message.error('保存失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
// 接口响应结束:解除禁用(无论成功失败)
|
||||
item.isSaving = false;
|
||||
});
|
||||
};
|
||||
|
||||
// 批量同步所有用户
|
||||
const handleSyncAll = () => {
|
||||
if (isSyncDisabled.value) return;
|
||||
|
||||
// 请求开始:禁用按钮+显示加载
|
||||
isSyncDisabled.value = true;
|
||||
loading.value = true;
|
||||
|
||||
useApiStore()
|
||||
.SyncFollow()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
message.success('后台开始同步...根据您关注的数量,需要的时间不一定...请耐心等待');
|
||||
} else {
|
||||
message.error('同步失败:' + (res.msg || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('同步异常:', err);
|
||||
message.error('同步失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
// 请求完成:解禁按钮+关闭加载
|
||||
isSyncDisabled.value = false;
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 原有样式保持不变,无需修改 */
|
||||
.dept-user-card-container {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 搜索+Tab容器 */
|
||||
.search-tab-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-wrapper {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 同步按钮样式 */
|
||||
.sync-btn {
|
||||
height: 40px !important;
|
||||
padding: 0 16px !important;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sync-btn-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 同步按钮禁用样式(浅色模式) */
|
||||
.sync-btn:disabled {
|
||||
background-color: #f5f5f5 !important;
|
||||
border-color: #d9d9d9 !important;
|
||||
color: #bfbfbf !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 搜索按钮样式 */
|
||||
.search-btn {
|
||||
height: 40px !important;
|
||||
padding: 0 16px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
width: 280px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100% !important;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
/* 搜索框过渡动画 */
|
||||
.search-input-fade-enter-from,
|
||||
.search-input-fade-leave-to {
|
||||
width: 0 !important;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input-fade-enter-active,
|
||||
.search-input-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Tab样式 */
|
||||
.custom-tabs {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list .ant-tabs-tab:first-child {
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list .ant-tabs-tab {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
/* 卡片列表容器 */
|
||||
.card-list-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 28px !important;
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.custom-card {
|
||||
border-radius: 12px !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
transition: all 0.3s ease !important;
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.custom-card:hover {
|
||||
border-color: #c7d2fe !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
position: relative;
|
||||
padding: 5px !important;
|
||||
flex: 1 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* 卡片开关位置 */
|
||||
.card-switch {
|
||||
position: absolute !important;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 卡片主要内容 */
|
||||
.card-main-content {
|
||||
display: flex;
|
||||
align-items: flex-start; /* 改为顶部对齐,避免多行签名导致头像居中错位 */
|
||||
gap: 20px;
|
||||
margin-top: 8px !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
/* 头像样式 */
|
||||
.avatar-wrapper {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px; /* 微调头像位置,与多行签名对齐 */
|
||||
}
|
||||
|
||||
.ant-avatar-lg {
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
background: linear-gradient(135deg, #4096ff, #69b1ff);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 卡片内容区域 */
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px; /* 缩小行间距,适配多行签名 */
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 核心修改:签名多行显示(高度控制) */
|
||||
.card-desc {
|
||||
font-size: 12px !important;
|
||||
color: #86909c;
|
||||
line-height: 1.5; /* 行高适配多行 */
|
||||
width: 100%;
|
||||
/* 保持原有宽度,不影响布局 */
|
||||
}
|
||||
|
||||
.signature-text {
|
||||
display: -webkit-box; /* 关键:启用弹性盒模型 */
|
||||
width: 250px; /* 固定宽度(原300px不变) */
|
||||
max-height: 54px; /* 控制显示行数:12px字体+1.5行高 → 2行=36px,3行=54px */
|
||||
line-height: 1.5; /* 行高,需与font-size配合计算高度 */
|
||||
overflow: hidden; /* 隐藏溢出内容 */
|
||||
text-overflow: ellipsis; /* 溢出显示省略号 */
|
||||
-webkit-line-clamp: 3; /* 关键:限制显示2行(可改为3行) */
|
||||
-webkit-box-orient: vertical; /* 垂直排列 */
|
||||
cursor: pointer; /* 提示可悬停查看完整内容 */
|
||||
word-break: break-all; /* 长单词/URL自动换行,避免横向溢出 */
|
||||
}
|
||||
|
||||
/* 路径+同步开关容器 */
|
||||
.card-path-sync-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 34px !important;
|
||||
margin-top: 8px !important; /* 微调与签名的间距,适配多行 */
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.path-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
max-width: calc(100% - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.path-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
font-size: 13px !important;
|
||||
color: #4e5969;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
height: 100%;
|
||||
line-height: 34px !important;
|
||||
}
|
||||
|
||||
.path-empty {
|
||||
color: #c9cdd4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 全量同步开关区域 */
|
||||
.sync-switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
width: 120px !important;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sync-label {
|
||||
font-size: 12px !important;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 编辑按钮样式 */
|
||||
.edit-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 50% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
background: transparent !important;
|
||||
color: #4096ff !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.edit-btn .anticon {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: transparent !important;
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
.edit-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
flex: 1;
|
||||
height: 100% !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 0 !important;
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding: 1px !important;
|
||||
}
|
||||
|
||||
.ant-card-bordered {
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 无更多数据 */
|
||||
.no-more-container {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-container {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.card-list-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 20px !important;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.search-tab-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
padding: 14px !important;
|
||||
}
|
||||
|
||||
.custom-card {
|
||||
height: auto !important; /* 移动端卡片高度自适应,避免多行签名溢出 */
|
||||
}
|
||||
|
||||
.path-area {
|
||||
max-width: calc(100% - 110px);
|
||||
}
|
||||
|
||||
.sync-switch-wrapper {
|
||||
width: 110px !important;
|
||||
}
|
||||
|
||||
.sync-label {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.sync-btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.sync-btn:disabled {
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
/* 移动端签名样式调整 */
|
||||
.signature-text {
|
||||
width: 250px; /* 移动端宽度缩减 */
|
||||
max-height: 30px; /* 移动端显示2行(12px字体+1.25行高) */
|
||||
line-height: 1.25;
|
||||
-webkit-line-clamp: 2; /* 保持2行显示 */
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================================== */
|
||||
/* 黑暗模式样式(html.dark-mode 强制生效) */
|
||||
/* ====================================== */
|
||||
html.dark-mode .dept-user-card-container .custom-card {
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .custom-card:hover {
|
||||
border-color: #6366f1 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .ant-card-bordered {
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .ant-card-bordered:hover {
|
||||
border-color: #6366f1 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .card-name {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .card-desc {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .path-text {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .path-empty {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .sync-label {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .loading-text,
|
||||
html.dark-mode .dept-user-card-container .no-more-container span {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* 黑暗模式下同步按钮样式 */
|
||||
html.dark-mode .dept-user-card-container .sync-btn {
|
||||
color: #d1d5db !important;
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
html.dark-mode .dept-user-card-container .sync-btn:hover {
|
||||
color: #f3f4f6 !important;
|
||||
border-color: #6b7280 !important;
|
||||
background-color: rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -6,26 +6,63 @@
|
||||
<h3 class="section-title">任务调度</h3>
|
||||
|
||||
<a-form-item has-feedback label="同步周期(分钟)" name="Cron" :wrapper-col="{ span: 6}" style="margin-left:30px">
|
||||
<!-- <a-input v-model:value="formState.Cron" placeholder="请输入数字" /> -->
|
||||
<a-input-number v-model:value="formState.Cron" placeholder="请输入数字" :min="1" />
|
||||
<a-input-number v-model:value="formState.Cron" placeholder="请输入数字" :min="15" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>同步任务的执行间隔,最小15分钟,建议使用默认值</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item has-feedback label="每次同步(条数)" name="BatchCount" :wrapper-col="{ span:6}" style="margin-left:30px">
|
||||
<a-input-number v-model:value="formState.BatchCount" placeholder="请输入查询条数(最大30)" :min="10" :max="30" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>每次同步获取的条数,范围10-30条,建议使用默认值</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 文件保存配置 -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">博主视频</h3>
|
||||
<h3 class="section-title">博主视频(仅关注有效)</h3>
|
||||
|
||||
<a-form-item has-feedback label="标题当文件名" name="UperUseViedoTitle" :wrapper-col="{ span: 6 }">
|
||||
<a-form-item has-feedback label="标题当文件名" name="UperUseViedoTitle" :wrapper-col="{ span: 20 }">
|
||||
<a-switch v-model:checked="formState.UperUseViedoTitle" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>启用后,关注的视频文件名将直接使用原视频标题,否则使用模板生成,如果既没有模板也没有开启,则系统默认使用视频Id作为文件名。</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item has-feedback label="不创建子目录" name="UperSaveTogether" :wrapper-col="{ span: 6 }">
|
||||
<a-form-item has-feedback label="不创建子目录" name="UperSaveTogether" :wrapper-col="{ span: 20 }">
|
||||
<a-switch v-model:checked="formState.UperSaveTogether" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>默认是按博主名字创建文件夹存储,启用后,关注的视频直接存放在根目录。</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 模板相关配置:只有关闭"标题当文件名"时才显示 -->
|
||||
<a-form-item has-feedback label="定义标题模板" name="FollowedTitleTemplate" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
|
||||
<a-select v-model:value="formState.FollowedTitleTemplate" :options="template_options" mode="multiple" size="middle" placeholder="请选择"></a-select>
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>选择生成文件名的占位符,顺序即为文件名顺序(需配合分隔符使用)</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item has-feedback label="模板分隔符" name="FollowedTitleSeparator" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
|
||||
<a-input v-model:value="formState.FollowedTitleSeparator" placeholder="请输入分隔符(如 - _ 或空)" style="width: 200px" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>占位符之间的连接符(如“-”“_”),为空则直接拼接</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 完整模板预览:只有关闭"标题当文件名"时才显示 -->
|
||||
<a-form-item label="完整模板预览" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
|
||||
<a-input v-model:value="formState.FullFollowedTitleTemplate" placeholder="最终的模板" disabled style="background: #f5f5f5" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-section" v-if="downImgVideo">
|
||||
@@ -33,24 +70,62 @@
|
||||
|
||||
<a-form-item has-feedback label="图文视频合成" name="DownImageVideo" :wrapper-col="{ span: 6 }">
|
||||
<a-switch v-model:checked="formState.DownImageVideo" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>启用后,图文视频将合成为视频文件下载</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item has-feedback label="单独下载音频" name="DownMp3" :wrapper-col="{ span: 6 }">
|
||||
<a-form-item has-feedback label="额外下载音频" name="DownMp3" :wrapper-col="{ span: 6 }">
|
||||
<a-switch v-model:checked="formState.DownMp3" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>启用后,将额外下载音频文件</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item has-feedback label="单独下载图片" name="DownImage" :wrapper-col="{ span: 6 }">
|
||||
<a-form-item has-feedback label="额外下载图片" name="DownImage" :wrapper-col="{ span: 6 }">
|
||||
<a-switch v-model:checked="formState.DownImage" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>启用后,将额外下载所有图片文件</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item has-feedback label="是否统一存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 10 }">
|
||||
<a-switch v-model:checked="formState.ImageViedoSaveAlone" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>
|
||||
启用后,所有图文视频统一存储到 Cookie(抖音授权) 设置的目录。<br />否则,按类型存储到对应文件夹(比如视频属于收藏的视频,则存储到收藏视频的目录)
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">其他配置</h3>
|
||||
|
||||
<a-form-item has-feedback label="日志保留(天数)" name="LogKeepDay" :wrapper-col="{ span: 6 }" style="margin-left:30px">
|
||||
<a-input-number v-model:value="formState.LogKeepDay" placeholder="请输入保留天数" :min="1" :max="90" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>系统运行日志的保留天数,范围1-90天,过期自动清理</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item has-feedback label="是否统一存储" name="AutoDistinct" :wrapper-col="{ span: 10 }">
|
||||
<a-switch v-model:checked="formState.AutoDistinct" />
|
||||
<div class="flex items-start mt-1 text-sm text-gray-500">
|
||||
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
|
||||
<span>
|
||||
启用后,同一个视频,只会下载一次。
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<a-form-item :wrapper-col="{ span: 12, offset: 5 }" class="form-actions">
|
||||
<a-space size="middle">
|
||||
@@ -64,22 +139,34 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, toRaw, ref, watch, onMounted } from 'vue';
|
||||
import { reactive, toRaw, ref, watch, onMounted, computed } from 'vue';
|
||||
import type { UnwrapRef } from 'vue';
|
||||
import { Form } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { useApiStore } from '@/store';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
// 模板字段:结构化数组
|
||||
const template_options = [
|
||||
{ label: '{Id}', value: '{Id}' },
|
||||
{ label: '{VideoTitle}', value: '{VideoTitle}' },
|
||||
{ label: '{FileHash}', value: '{FileHash}' },
|
||||
// { label: '{FileSize}', value: '{FileSize}' },
|
||||
{ label: '{Resolution}', value: '{Resolution}' },
|
||||
// { label: '{SyncTime}', value: '{SyncTime}' },
|
||||
{ label: '{ReleaseTime}', value: '{ReleaseTime}' },
|
||||
];
|
||||
|
||||
// 状态定义
|
||||
const componentDisabled = ref(true);
|
||||
const downImgVideo = ref(false);
|
||||
|
||||
// 表单数据结构
|
||||
// 表单数据结构(新增 FullFollowedTitleTemplate 字段)
|
||||
interface FormState {
|
||||
Cron: number;
|
||||
Id: string;
|
||||
@@ -91,6 +178,11 @@ interface FormState {
|
||||
LogKeepDay: number;
|
||||
DownImage: boolean;
|
||||
DownMp3: boolean;
|
||||
ImageViedoSaveAlone: boolean;
|
||||
FollowedTitleTemplate: string[]; // 占位符数组
|
||||
FollowedTitleSeparator: string; // 分隔符
|
||||
FullFollowedTitleTemplate: string; // 新增:完整模板字符串(自动生成)
|
||||
AutoDistinct: boolean;
|
||||
}
|
||||
|
||||
// 表单初始数据
|
||||
@@ -105,44 +197,70 @@ const formState: UnwrapRef<FormState> = reactive({
|
||||
DownImageVideoFromEnv: false,
|
||||
DownMp3: false,
|
||||
DownImage: false,
|
||||
ImageViedoSaveAlone: true,
|
||||
FollowedTitleTemplate: [],
|
||||
FollowedTitleSeparator: '',
|
||||
FullFollowedTitleTemplate: '', // 初始为空
|
||||
AutoDistinct: false,
|
||||
});
|
||||
|
||||
// 表单校验规则
|
||||
// 实时计算完整模板(可选:让用户实时预览,提交时无需重复计算)
|
||||
const computeFullTemplate = computed(() => {
|
||||
return formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator);
|
||||
});
|
||||
|
||||
// 监听占位符/分隔符变化,实时更新预览(可选)
|
||||
watch(
|
||||
[() => [...formState.FollowedTitleTemplate], () => formState.FollowedTitleSeparator],
|
||||
() => {
|
||||
if (!formState.UperUseViedoTitle) {
|
||||
// 只有关闭标题当文件名时才更新预览
|
||||
formState.FullFollowedTitleTemplate = computeFullTemplate.value;
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 监听"标题当文件名"开关变化
|
||||
watch(
|
||||
() => formState.UperUseViedoTitle,
|
||||
(isEnabled) => {
|
||||
if (isEnabled) {
|
||||
// 开启时清空模板相关数据,避免数据残留
|
||||
formState.FollowedTitleTemplate = [];
|
||||
formState.FollowedTitleSeparator = '';
|
||||
formState.FullFollowedTitleTemplate = '';
|
||||
// 清空相关表单项的校验状态
|
||||
formRef.value?.clearValidate(['FollowedTitleTemplate', 'FollowedTitleSeparator', 'FullFollowedTitleTemplate']);
|
||||
} else {
|
||||
// 关闭时重新计算完整模板
|
||||
formState.FullFollowedTitleTemplate = computeFullTemplate.value;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 表单校验规则(新增 FullFollowedTitleTemplate 校验)
|
||||
const rules: Record<string, Rule[]> = {
|
||||
// Cron: [
|
||||
// { required: true, message: '请输入任务调度周期', trigger: 'change' },
|
||||
// {
|
||||
// validator: (rule, value) => {
|
||||
// // 验证数字或cron表达式
|
||||
// if (/^\d+$/.test(value) || /^(\d+\s*){5,6}$/.test(value)) {
|
||||
// return Promise.resolve();
|
||||
// }
|
||||
// return Promise.reject(new Error('请输入有效的数字或Cron表达式'));
|
||||
// },
|
||||
// trigger: 'change',
|
||||
// },
|
||||
// ],
|
||||
// BatchCount: [
|
||||
// { required: true, message: '请输入每次查询条数', trigger: 'change' },
|
||||
// { type: 'number', min: 1, max: 100, message: '查询条数应在1-100之间', trigger: 'change' },
|
||||
// ],
|
||||
// LogKeepDay: [
|
||||
// { required: true, message: '请输入日志保留天数', trigger: 'change' },
|
||||
// { type: 'number', min: 1, max: 90, message: '保留天数应在1-90之间', trigger: 'change' },
|
||||
// ],
|
||||
FollowedTitleSeparator: [{ max: 5, message: '分隔符长度不能超过5个字符', trigger: 'change' }],
|
||||
FullFollowedTitleTemplate: [{ max: 200, message: '完整模板字符串长度不能超过200个字符', trigger: 'change' }],
|
||||
};
|
||||
|
||||
// 布局配置
|
||||
const labelCol = { style: { width: '150px', textAlign: 'right' } };
|
||||
const wrapperCol = { span: 12 };
|
||||
|
||||
// 获取配置数据
|
||||
// 获取配置数据(适配 FullFollowedTitleTemplate 字段)
|
||||
const getConfig = () => {
|
||||
useApiStore()
|
||||
.apiGetConfig()
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
// 使用Object.assign避免重复赋值
|
||||
// 优先从 FullFollowedTitleTemplate 解析占位符数组(确保数据一致)
|
||||
const fullTemplate = res.data.FullFollowedTitleTemplate || res.data.followedTitleTemplate || '';
|
||||
const parsedTemplateArr = parseTemplateToArr(fullTemplate);
|
||||
|
||||
// 赋值所有字段(包含新增的 FullFollowedTitleTemplate)
|
||||
Object.assign(formState, {
|
||||
Cron: res.data.cron,
|
||||
Id: res.data.id,
|
||||
@@ -154,9 +272,13 @@ const getConfig = () => {
|
||||
LogKeepDay: res.data.logKeepDay,
|
||||
DownImage: res.data.downImage,
|
||||
DownMp3: res.data.downMp3,
|
||||
FollowedTitleTemplate: parsedTemplateArr,
|
||||
FollowedTitleSeparator: res.data.followedTitleSeparator || '',
|
||||
FullFollowedTitleTemplate: fullTemplate, // 回显完整模板
|
||||
ImageViedoSaveAlone: res.data.imageViedoSaveAlone,
|
||||
AutoDistinct: res.data.autoDistinct,
|
||||
});
|
||||
|
||||
// 监听环境变量配置,控制是否显示图片视频下载选项
|
||||
downImgVideo.value = res.data.downImageVideoFromEnv;
|
||||
} else {
|
||||
message.error(res.erro || '获取配置失败', 8);
|
||||
@@ -168,26 +290,46 @@ const getConfig = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 模板字符串 → 占位符数组(原有逻辑不变)
|
||||
const parseTemplateToArr = (templateStr: string | null | undefined) => {
|
||||
if (!templateStr || typeof templateStr !== 'string' || templateStr.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
const placeholderReg = /\{[a-zA-Z0-9]+\}/g;
|
||||
const matchedPlaceholders = templateStr.match(placeholderReg) || [];
|
||||
const validPlaceholderValues = template_options.map((item) => item.value);
|
||||
const validPlaceholders = matchedPlaceholders.filter((placeholder) => validPlaceholderValues.includes(placeholder));
|
||||
return Array.from(new Set(validPlaceholders));
|
||||
};
|
||||
|
||||
// 组件挂载时获取配置
|
||||
onMounted(() => {
|
||||
getConfig();
|
||||
});
|
||||
|
||||
// // 监听环境变量变化
|
||||
// watch(
|
||||
// () => formState.DownImageVideoFromEnv,
|
||||
// (value) => {
|
||||
// downImgVideo.value = value;
|
||||
// }
|
||||
// );
|
||||
|
||||
// 提交表单
|
||||
// 提交表单(核心:自动生成完整模板字符串)
|
||||
const onSubmit = () => {
|
||||
formRef.value
|
||||
.validate()
|
||||
.then(() => {
|
||||
// 1. 如果开启了标题当文件名,清空模板相关字段
|
||||
let fullTemplate = '';
|
||||
let templateTitle = '';
|
||||
if (!formState.UperUseViedoTitle) {
|
||||
// 自动拼接完整模板字符串(数组 + 分隔符)
|
||||
fullTemplate = formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator);
|
||||
}
|
||||
|
||||
// 2. 构造提交数据(包含三个模板相关字段)
|
||||
const submitData = {
|
||||
...toRaw(formState),
|
||||
FullFollowedTitleTemplate: fullTemplate, // 确保提交最新拼接结果
|
||||
FollowedTitleTemplate: formState.FollowedTitleTemplate.join(''),
|
||||
};
|
||||
|
||||
// 3. 提交接口
|
||||
useApiStore()
|
||||
.apiUpdateConfig(toRaw(formState))
|
||||
.apiUpdateConfig(submitData)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
message.success('修改成功,同步任务将在5-10秒按新配置运行...');
|
||||
@@ -215,8 +357,11 @@ const onUpdate = () => {
|
||||
// 取消编辑
|
||||
const onCancel = () => {
|
||||
componentDisabled.value = true;
|
||||
// 可以考虑添加重置表单的逻辑
|
||||
formRef.value?.clearValidate();
|
||||
// 取消时恢复完整模板预览(只有关闭标题当文件名时)
|
||||
if (!formState.UperUseViedoTitle) {
|
||||
formState.FullFollowedTitleTemplate = computeFullTemplate.value;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -229,6 +374,9 @@ const onCancel = () => {
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -240,44 +388,6 @@ const onCancel = () => {
|
||||
border-left: 3px solid @primary-color;
|
||||
}
|
||||
|
||||
// // 关键改动:强制标签靠左并添加100px左侧间距
|
||||
// .ant-form-item-label {
|
||||
// text-align: left !important; /* 强制靠左对齐,覆盖默认右对齐 */
|
||||
// padding-right: 0 !important; /* 清除默认右侧内边距 */
|
||||
// margin-left: 100px !important; /* 标签左侧间距100px */
|
||||
// }
|
||||
|
||||
// 保持配置项内容区域与标签对齐(可选,根据实际布局调整)
|
||||
// .ant-form-item-control {
|
||||
// margin-left: 0 !important; /* 清除之前可能添加的左侧间距,避免双重缩进 */
|
||||
// }
|
||||
|
||||
.help-text {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cron-link {
|
||||
color: @primary-color;
|
||||
text-decoration: underline;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #096dd9;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
@@ -286,7 +396,19 @@ const onCancel = () => {
|
||||
color: rgb(164 158 158) !important;
|
||||
background-color: #e6e6e6 !important;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
// 统一提醒文字样式
|
||||
:deep(.flex.items-start.mt-1.text-sm.text-gray-500) {
|
||||
line-height: 1.6;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
// 禁用输入框样式优化
|
||||
:deep(.ant-input-disabled) {
|
||||
color: #666 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -107,6 +107,18 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/workplace/Workplace.vue'),
|
||||
},
|
||||
{
|
||||
path: '/follow',
|
||||
name: '关注列表',
|
||||
meta: {
|
||||
icon: 'HeartOutlined',
|
||||
view: 'self',
|
||||
target: '_self',
|
||||
renderMenu: true,
|
||||
cacheable: false,
|
||||
},
|
||||
component: () => import('@/pages/followd/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/cok',
|
||||
name: '抖音授权',
|
||||
@@ -119,6 +131,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('@/pages/cok/Table.vue'),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/set',
|
||||
name: '系统配置',
|
||||
|
||||
@@ -146,6 +146,14 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
|
||||
});
|
||||
}
|
||||
async function CookieList() {
|
||||
return http.request<any, Response<any>>('/api/config/list', 'get').then(r => {
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function UpdateConfig(param: object) {
|
||||
return http.request<any, Response<any>>('/api/config/update', 'post_json', param).then(r => {
|
||||
@@ -162,16 +170,47 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
|
||||
});
|
||||
}
|
||||
// async function playViedo(id: string) {
|
||||
// return http.request<any, Response<any>>('/api/video/play/' + id, 'get').then(r => {
|
||||
// return r.data;
|
||||
// }).finally(() => {
|
||||
//follows
|
||||
async function FollowList(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/paged', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
|
||||
// });
|
||||
// }
|
||||
});
|
||||
}
|
||||
//同步关注列表
|
||||
async function SyncFollow() {
|
||||
return http.request<any, Response<any>>('/api/follow/sync', 'get').then(r => {
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//更新同步关注者状态
|
||||
async function OpenOrCloseSync(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/openOrCloseSync', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//更新同步关注者状态
|
||||
async function OpenOrCloseFullSync(param: object) {
|
||||
return http.request<any, Response<any>>('/api/follow/openOrCloseFullSync', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
//重新下载
|
||||
async function ReDownViedos(param: object) {
|
||||
return http.request<any, Response<any>>('/api/video/redown', 'post_json', param).then(r => {
|
||||
return r.data;
|
||||
}).finally(() => {
|
||||
|
||||
});
|
||||
}
|
||||
return {
|
||||
// playViedo,
|
||||
deleteCookie,
|
||||
UpdateConfig,
|
||||
apiCheckInitStatus,
|
||||
@@ -184,6 +223,12 @@ export const useApiStore = defineStore('coreapi', () => {
|
||||
StartJobNow,
|
||||
VideoStatics,
|
||||
VideoPageList,
|
||||
CookiePageList
|
||||
CookiePageList,
|
||||
CookieList,
|
||||
FollowList,
|
||||
SyncFollow,
|
||||
OpenOrCloseSync,
|
||||
OpenOrCloseFullSync,
|
||||
ReDownViedos
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dbconn": "",
|
||||
"dbtype": "Sqlite",
|
||||
"DOWN_IMGVIDEO": ""
|
||||
"DOWN_IMGVIDEO": "1"
|
||||
}
|
||||
@@ -1259,7 +1259,8 @@ namespace dy.net.dto
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
//public int height { get; set; }
|
||||
[JsonProperty("height")]
|
||||
public int Height { get; set; }
|
||||
///// <summary>
|
||||
/////
|
||||
///// </summary>
|
||||
@@ -1272,7 +1273,8 @@ namespace dy.net.dto
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
//public int width { get; set; }
|
||||
[JsonProperty("width")]
|
||||
public int Width { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ namespace dy.net.dto
|
||||
{
|
||||
public class DouyinVideoPageRequestDto : PageRequestDto
|
||||
{
|
||||
// 3. 引用类型(string)如果允许为null,显式声明为 string?
|
||||
public string? Tag { get; set; }
|
||||
/// <summary>
|
||||
/// 标题查询。
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Author { get; set; }
|
||||
//public string? Name { get; set; }
|
||||
|
||||
public string? ViedoType { get; set; }
|
||||
|
||||
// 4. 泛型集合(List<string>)允许为null,声明为 List<string>?
|
||||
public List<string>? Dates { get; set; }
|
||||
public List<string>? Dates2 { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -23,4 +27,25 @@ namespace dy.net.dto
|
||||
|
||||
public int PageSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
|
||||
public class FollowRequestDto: PageRequestDto
|
||||
{
|
||||
public string FollowUserName { get; set; }
|
||||
|
||||
public string MySelfId { get; set; }
|
||||
}
|
||||
|
||||
public class FollowUpdateDto
|
||||
{
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public bool OpenSync { get; set; }
|
||||
|
||||
public bool FullSync { get; set; }
|
||||
|
||||
public string SavePath { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace dy.net.dto
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务配置实体
|
||||
/// </summary>
|
||||
public class JobConfig
|
||||
{
|
||||
public JobConfig(Type jobType, string jobKey, string triggerKey, string description)
|
||||
{
|
||||
JobType = jobType ?? throw new ArgumentNullException(nameof(jobType));
|
||||
JobKey = jobKey ?? throw new ArgumentNullException(nameof(jobKey));
|
||||
TriggerKey = triggerKey ?? throw new ArgumentNullException(nameof(triggerKey));
|
||||
Description = description ?? throw new ArgumentNullException(nameof(description));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务类型
|
||||
/// </summary>
|
||||
public Type JobType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务Key
|
||||
/// </summary>
|
||||
public string JobKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 触发器Key
|
||||
/// </summary>
|
||||
public string TriggerKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务描述
|
||||
/// </summary>
|
||||
public string Description { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.net.dto
|
||||
{
|
||||
public class MediaMergeRequest
|
||||
{
|
||||
/// <summary>网络图片地址数组(必填)</summary>
|
||||
public List<string> ImageUrls { get; set; }
|
||||
|
||||
/// <summary>网络MP3地址数组(必填)</summary>
|
||||
public List<string> AudioUrls { get; set; }
|
||||
|
||||
/// <summary>每张图片显示时长(秒,默认3秒)</summary>
|
||||
public int ImageDurationPerSecond { get; set; } = 3;
|
||||
|
||||
/// <summary>视频分辨率(格式:1920x1080,默认1920x1080)</summary>
|
||||
public int VideoWidth { get; set; } = 1080;
|
||||
public int VideoHeight { get; set; } = 1920;
|
||||
|
||||
/// <summary>输出视频格式(默认mp4)</summary>
|
||||
public string OutputFormat { get; set; } = "mp4";
|
||||
|
||||
/// <summary>视频帧率(默认25)</summary>
|
||||
public int VideoFps { get; set; } = 25;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace dy.net.dto
|
||||
{
|
||||
public class ReDownViedoDto
|
||||
{
|
||||
public List<string> Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace dy.net.dto
|
||||
{
|
||||
public class VideoTitleDataTemplate
|
||||
{
|
||||
/// <summary> 对应 {id} </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary> 对应 {VideoTitle} </summary>
|
||||
public string VideoTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary> 对应 {SyncTime}(同步时间) </summary>
|
||||
//public DateTime? SyncTime { get; set; }
|
||||
|
||||
/// <summary> 对应 {ReleaseTime}(发布时间) </summary>
|
||||
public DateTime? ReleaseTime { get; set; }
|
||||
|
||||
/// <summary> 对应 {FileHash}(文件哈希值) </summary>
|
||||
public string FileHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary> 对应 {Resolution}(分辨率,如 1920x1080) </summary>
|
||||
public string Resolution { get; set; } = string.Empty;
|
||||
|
||||
/// <summary> 对应 {FileSize}(文件大小,单位:字节) </summary>
|
||||
//public long FileSize { get; set; } = 0;
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,15 @@ namespace dy.net.dto
|
||||
[Description("图片视频")]
|
||||
ImageVideo = 4
|
||||
}
|
||||
|
||||
|
||||
public enum QuartzJobTypeEnum
|
||||
{
|
||||
[Description("[Favorite]")]
|
||||
Favorite = 1,
|
||||
[Description("[Collect]")]
|
||||
Collect = 2,
|
||||
[Description("[Followed]")]
|
||||
Followed = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,6 @@
|
||||
<None Include="dy.net.sln" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="expand\dy.image\dy.image.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="db\dy.sqlite">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -93,7 +89,7 @@
|
||||
<None Update="Dockerfile-dev">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Dockerfile-img">
|
||||
<None Update="Dockerfile">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dy.net", "dy.net.csproj", "
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dy.expand", "dy.expand", "{1503FF41-A045-48DF-B92C-DB81BAAAFE5B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dy.image", "expand\dy.image\dy.image.csproj", "{38404DA7-A852-4960-9E95-8F70459AEB60}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -19,17 +17,10 @@ Global
|
||||
{680660EF-ACAE-43A9-AB6C-B75532E758AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{680660EF-ACAE-43A9-AB6C-B75532E758AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{680660EF-ACAE-43A9-AB6C-B75532E758AD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{38404DA7-A852-4960-9E95-8F70459AEB60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{38404DA7-A852-4960-9E95-8F70459AEB60}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{38404DA7-A852-4960-9E95-8F70459AEB60}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{38404DA7-A852-4960-9E95-8F70459AEB60}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{38404DA7-A852-4960-9E95-8F70459AEB60} = {1503FF41-A045-48DF-B92C-DB81BAAAFE5B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {8238199F-0C28-42F2-ACFD-E8CBCCB2AB80}
|
||||
EndGlobalSection
|
||||
|
||||
@@ -14,29 +14,12 @@ namespace dy.image
|
||||
public DownloadHelper(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(60); // 下载超时30秒
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(60); // 下载超时60秒
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept-Language", "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36");
|
||||
_httpClient.DefaultRequestHeaders.Add("Referer", "https://www.douyin.com");
|
||||
}
|
||||
|
||||
/// <summary>下载网络文件到指定路径</summary>
|
||||
public async Task DownloadFileAsync(string url, string savePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) throw new ArgumentNullException(nameof(url));
|
||||
if (string.IsNullOrEmpty(savePath)) throw new ArgumentNullException(nameof(savePath));
|
||||
|
||||
// 创建目录(如果不存在)
|
||||
var directory = Path.GetDirectoryName(savePath)!;
|
||||
if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
// 下载文件
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode(); // 非2xx状态码抛出异常
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
|
||||
await stream.CopyToAsync(fileStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{
|
||||
if(downImage)
|
||||
{
|
||||
for (int i = 0; i < rawImages.Count(); i++)
|
||||
for (int i = 0; i < rawImages.Length; i++)
|
||||
{
|
||||
string sourcePath = rawImages[i];
|
||||
// 重命名为有规律的文件名,如 temp_001.jpg, temp_002.png
|
||||
@@ -66,7 +66,7 @@
|
||||
{
|
||||
if(downMp3)
|
||||
{
|
||||
for (int i = 0; i < rawAudios.Count(); i++)
|
||||
for (int i = 0; i < rawAudios.Length; i++)
|
||||
{
|
||||
string sourcePath = rawAudios[i];
|
||||
// 重命名为有规律的文件名,如 temp_001.mp3, temp_002.mp3
|
||||
|
||||
@@ -180,6 +180,10 @@ namespace dy.net.extension
|
||||
client.DefaultRequestHeaders.Add("Referer", "https://www.douyin.com");
|
||||
});
|
||||
|
||||
services.AddHttpClient("dy_follow", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Referrer = new Uri("https://www.douyin.com/user/self?showTab=like");
|
||||
});
|
||||
|
||||
services.AddHttpClient("dy_favorite", client =>
|
||||
{
|
||||
@@ -201,6 +205,10 @@ namespace dy.net.extension
|
||||
// 连接超时(建立连接的超时时间)
|
||||
ConnectTimeout = TimeSpan.FromSeconds(60)
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
||||
})
|
||||
// 配置客户端默认请求头
|
||||
.ConfigureHttpClient(client =>
|
||||
{
|
||||
@@ -208,26 +216,6 @@ namespace dy.net.extension
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36");
|
||||
client.DefaultRequestHeaders.Add("Referer", "https://www.douyin.com");
|
||||
});
|
||||
//services.AddHttpClient("dy_down_fav")
|
||||
// .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
// {
|
||||
// // 控制并发连接数(根据服务器承受能力调整,建议5-20)
|
||||
// MaxConnectionsPerServer = 5,
|
||||
// // 禁用代理自动检测(减少不必要的延迟)
|
||||
// UseProxy = false,
|
||||
// // 连接超时(建立连接的超时时间)
|
||||
// ConnectTimeout = TimeSpan.FromSeconds(60)
|
||||
// });
|
||||
//services.AddHttpClient("dy_down_uper")
|
||||
// .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
// {
|
||||
// // 控制并发连接数(根据服务器承受能力调整,建议5-20)
|
||||
// MaxConnectionsPerServer = 5,
|
||||
// // 禁用代理自动检测(减少不必要的延迟)
|
||||
// UseProxy = false,
|
||||
// // 连接超时(建立连接的超时时间)
|
||||
// ConnectTimeout = TimeSpan.FromSeconds(60)
|
||||
// });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,47 +6,45 @@ using System;
|
||||
|
||||
namespace dy.net.job
|
||||
{
|
||||
public class DouyinCollectSyncJob : DouyinBaseSyncJob
|
||||
public class DouyinCollectSyncJob : DouyinBasicSyncJob
|
||||
{
|
||||
public DouyinCollectSyncJob(
|
||||
DouyinCookieService dyCookieService,
|
||||
DouyinHttpClientService dyHttpClientService,
|
||||
DouyinVideoService dyCollectVideoService,
|
||||
DouyinCommonService commonService,IServiceProvider serviceProvider,IWebHostEnvironment webHostEnvironment)
|
||||
: base(dyCookieService, dyHttpClientService, dyCollectVideoService, commonService, serviceProvider,webHostEnvironment) { }
|
||||
DouyinCookieService douyinCookieService,
|
||||
DouyinHttpClientService douyinHttpClientService,
|
||||
DouyinVideoService douyinVideoService,
|
||||
DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService)
|
||||
: base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService,douyinFollowService, douyinMergeVideoService) { }
|
||||
|
||||
protected override string JobType => "collect";
|
||||
protected override string JobType => SystemStaticUtil.DY_COLLECTS;
|
||||
|
||||
protected override async Task BeforeProcessCookies()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
if (now.Hour == 1 && now.Minute < 30)
|
||||
{
|
||||
_commonService.UpdateAllCookieSyncedToZero();
|
||||
douyinCommonService.UpdateAllCookieSyncedToZero();
|
||||
//顺手清理下日志文件
|
||||
LogFileCleaner.CleanOldLogFiles(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs"), 1);
|
||||
await Task.Delay(200);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<List<DouyinUserCookie>> GetValidCookies()
|
||||
protected override async Task<List<DouyinCookie>> GetValidCookies()
|
||||
{
|
||||
var cookies = await _dyCookieService.GetAllCookies();
|
||||
return cookies.Where(c => !string.IsNullOrWhiteSpace(c.SavePath)).ToList();
|
||||
return await douyinCookieService.GetAllOpendAsync(x => !string.IsNullOrWhiteSpace(x.SavePath));
|
||||
}
|
||||
|
||||
protected override bool IsCookieValid(DouyinUserCookie cookie)
|
||||
protected override bool IsCookieValid(DouyinCookie cookie)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(cookie.Cookies) && cookie.Cookies.Length >= 1000 &&
|
||||
!string.IsNullOrWhiteSpace(cookie.SavePath);
|
||||
return !string.IsNullOrWhiteSpace(cookie.Cookies)&& !string.IsNullOrWhiteSpace(cookie.SavePath);
|
||||
}
|
||||
|
||||
protected override async Task<DouyinVideoInfo> FetchVideoData(DouyinUserCookie cookie, string cursor)
|
||||
protected override async Task<DouyinVideoInfo> FetchVideoData(DouyinCookie cookie, string cursor,string uperUid)
|
||||
{
|
||||
return await _douyinService.SyncCollectVideos(cursor, count, cookie.Cookies);
|
||||
return await douyinHttpClientService.SyncCollectVideos(cursor, count, cookie.Cookies);
|
||||
}
|
||||
|
||||
protected override bool ShouldContinueSync(DouyinUserCookie cookie, DouyinVideoInfo data)
|
||||
protected override bool ShouldContinueSync(DouyinCookie cookie, DouyinVideoInfo data, DouyinFollowed followed = null)
|
||||
{
|
||||
return data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0;
|
||||
}
|
||||
@@ -56,32 +54,18 @@ namespace dy.net.job
|
||||
return data?.Cursor ?? "0";
|
||||
}
|
||||
|
||||
protected override string CreateSaveFolder(DouyinUserCookie cookie, Aweme item, string tag1, string tag2, AppConfig config)
|
||||
{
|
||||
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : TikTokFileNameHelper.SanitizePath(tag1);
|
||||
var folder = Path.Combine(cookie.SavePath, safeTag1, $"{TikTokFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}");
|
||||
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
protected override string GetVideoFileName(DouyinUserCookie cookie, Aweme item)
|
||||
{
|
||||
var bitRate=item.Video.BitRate.FirstOrDefault();
|
||||
return $"{item.AwemeId}.{bitRate.Format}";
|
||||
}
|
||||
|
||||
protected override string GetAuthorAvatarBasePath(DouyinUserCookie cookie)
|
||||
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
|
||||
{
|
||||
return Path.Combine(cookie.SavePath, "author");
|
||||
}
|
||||
|
||||
protected override async Task HandleSyncCompletion(DouyinUserCookie cookie, int syncCount)
|
||||
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount)
|
||||
{
|
||||
if (syncCount > 0)
|
||||
{
|
||||
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
|
||||
cookie.CollHasSyncd = 1;
|
||||
await _dyCookieService.UpdateAsync(cookie);
|
||||
await douyinCookieService.UpdateAsync(cookie);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -90,12 +74,21 @@ namespace dy.net.job
|
||||
}
|
||||
|
||||
|
||||
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinUserCookie cookie, Aweme item)
|
||||
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item)
|
||||
{
|
||||
return new VideoEntityDifferences
|
||||
{
|
||||
VideoType = VideoTypeEnum.Collect,
|
||||
};
|
||||
}
|
||||
|
||||
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed)
|
||||
{
|
||||
var (tag1, _, _) = GetVideoTags(item);
|
||||
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizePath(tag1);
|
||||
var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}");
|
||||
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,35 +8,34 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace dy.net.job
|
||||
{
|
||||
public class DouyinFavoritSyncJob : DouyinBaseSyncJob
|
||||
public class DouyinFavoritSyncJob : DouyinBasicSyncJob
|
||||
{
|
||||
public DouyinFavoritSyncJob(
|
||||
DouyinCookieService dyCookieService,
|
||||
DouyinHttpClientService dyHttpClientService,
|
||||
DouyinVideoService dyCollectVideoService,
|
||||
DouyinCommonService commonService, IServiceProvider serviceProvider, IWebHostEnvironment webHostEnvironment)
|
||||
: base(dyCookieService, dyHttpClientService, dyCollectVideoService, commonService, serviceProvider, webHostEnvironment) { }
|
||||
DouyinCookieService douyinCookieService,
|
||||
DouyinHttpClientService douyinHttpClientService,
|
||||
DouyinVideoService douyinVideoService,
|
||||
DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService)
|
||||
: base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService) { }
|
||||
|
||||
protected override string JobType => "favorite";
|
||||
protected override async Task<List<DouyinUserCookie>> GetValidCookies()
|
||||
protected override string JobType => SystemStaticUtil.DY_FAVORITES;
|
||||
protected override async Task<List<DouyinCookie>> GetValidCookies()
|
||||
{
|
||||
var cookies = await _dyCookieService.GetAllCookies();
|
||||
return cookies.Where(c => !string.IsNullOrWhiteSpace(c.FavSavePath)).ToList();
|
||||
return await douyinCookieService.GetAllOpendAsync(x=> !string.IsNullOrWhiteSpace(x.FavSavePath));
|
||||
}
|
||||
|
||||
protected override bool IsCookieValid(DouyinUserCookie cookie)
|
||||
protected override bool IsCookieValid(DouyinCookie cookie)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(cookie.Cookies) && cookie.Cookies.Length >= 1000 &&
|
||||
!string.IsNullOrWhiteSpace(cookie.FavSavePath) &&
|
||||
!string.IsNullOrWhiteSpace(cookie.SecUserId) && cookie.SecUserId.Length >= 10;
|
||||
}
|
||||
|
||||
protected override async Task<DouyinVideoInfo> FetchVideoData(DouyinUserCookie cookie, string cursor)
|
||||
protected override async Task<DouyinVideoInfo> FetchVideoData(DouyinCookie cookie, string cursor,string uperUid)
|
||||
{
|
||||
return await _douyinService.SyncFavoriteVideos(count, cursor, cookie.SecUserId, cookie.Cookies);
|
||||
return await douyinHttpClientService.SyncFavoriteVideos(count, cursor, cookie.SecUserId, cookie.Cookies);
|
||||
}
|
||||
|
||||
protected override bool ShouldContinueSync(DouyinUserCookie cookie, DouyinVideoInfo data)
|
||||
protected override bool ShouldContinueSync(DouyinCookie cookie, DouyinVideoInfo data, DouyinFollowed followed=null)
|
||||
{
|
||||
return data != null && data.HasMore == 1 && cookie.FavHasSyncd == 0;
|
||||
}
|
||||
@@ -46,32 +45,19 @@ namespace dy.net.job
|
||||
return data?.MaxCursor ?? "0";
|
||||
}
|
||||
|
||||
protected override string CreateSaveFolder(DouyinUserCookie cookie, Aweme item, string tag1, string tag2, AppConfig config)
|
||||
{
|
||||
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : TikTokFileNameHelper.SanitizePath(tag1);
|
||||
var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{TikTokFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}");
|
||||
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
protected override string GetVideoFileName(DouyinUserCookie cookie, Aweme item)
|
||||
{
|
||||
var bitRate = item.Video.BitRate.FirstOrDefault();
|
||||
return $"{item.AwemeId}.{bitRate.Format}";
|
||||
}
|
||||
|
||||
protected override string GetAuthorAvatarBasePath(DouyinUserCookie cookie)
|
||||
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
|
||||
{
|
||||
return Path.Combine(cookie.FavSavePath, "author");
|
||||
}
|
||||
|
||||
protected override async Task HandleSyncCompletion(DouyinUserCookie cookie, int syncCount)
|
||||
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount)
|
||||
{
|
||||
if (syncCount > 0)
|
||||
{
|
||||
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
|
||||
cookie.FavHasSyncd = 1;
|
||||
await _dyCookieService.UpdateAsync(cookie);
|
||||
await douyinCookieService.UpdateAsync(cookie);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -79,12 +65,21 @@ namespace dy.net.job
|
||||
}
|
||||
}
|
||||
|
||||
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinUserCookie cookie, Aweme item)
|
||||
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item)
|
||||
{
|
||||
return new VideoEntityDifferences
|
||||
{
|
||||
VideoType = VideoTypeEnum.Favorite
|
||||
};
|
||||
}
|
||||
|
||||
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed)
|
||||
{
|
||||
var (tag1, _, _) = GetVideoTags(item);
|
||||
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizePath(tag1);
|
||||
var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}");
|
||||
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Newtonsoft.Json;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.net.job
|
||||
{
|
||||
public class DouyinUperPostSyncJob : DouyinBaseSyncJob
|
||||
{
|
||||
public DouyinUperPostSyncJob(
|
||||
DouyinCookieService dyCookieService,
|
||||
DouyinHttpClientService dyHttpClientService,
|
||||
DouyinVideoService dyCollectVideoService,
|
||||
DouyinCommonService commonService, IServiceProvider serviceProvider, IWebHostEnvironment webHostEnvironment)
|
||||
: base(dyCookieService, dyHttpClientService, dyCollectVideoService, commonService, serviceProvider, webHostEnvironment) { }
|
||||
|
||||
protected override string JobType => "dyuploder";
|
||||
|
||||
protected override async Task<List<DouyinUserCookie>> GetValidCookies()
|
||||
{
|
||||
var cookies = await _dyCookieService.GetAllCookies();
|
||||
return cookies.Where(x => !string.IsNullOrWhiteSpace(x.UpSecUserIds) && !string.IsNullOrWhiteSpace(x.UpSavePath)).ToList();
|
||||
}
|
||||
|
||||
protected override bool IsCookieValid(DouyinUserCookie cookie)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(cookie.Cookies) && !string.IsNullOrWhiteSpace(cookie.UpSavePath) && !string.IsNullOrWhiteSpace(cookie.UpSecUserIds);
|
||||
}
|
||||
|
||||
protected override async Task<DouyinVideoInfo> FetchVideoData(DouyinUserCookie cookie, string cursor)
|
||||
{
|
||||
// 简化处理:假设只同步第一个UP主
|
||||
var ups = JsonConvert.DeserializeObject<List<DouyinUpSecUserIdDto>>(cookie.UpSecUserIds);
|
||||
var firstUpId = ups?.FirstOrDefault()?.uid;
|
||||
if (string.IsNullOrEmpty(firstUpId)) return null;
|
||||
|
||||
return await _douyinService.SyncUpderPostVideos(count, cursor, firstUpId, cookie.Cookies);
|
||||
}
|
||||
|
||||
protected override bool ShouldContinueSync(DouyinUserCookie cookie, DouyinVideoInfo data)
|
||||
{
|
||||
return data != null && data.HasMore == 1 && cookie.UperSyncd == 0;
|
||||
}
|
||||
|
||||
protected override string GetNextCursor(DouyinVideoInfo data)
|
||||
{
|
||||
return data?.MaxCursor ?? "0";
|
||||
}
|
||||
|
||||
protected override string CreateSaveFolder(DouyinUserCookie cookie, Aweme item, string tag1, string tag2,AppConfig config)
|
||||
{
|
||||
// UP主视频通常按作者名创建文件夹
|
||||
var authorName = string.IsNullOrWhiteSpace(item.Author?.Nickname) ? "UnknownAuthor" : TikTokFileNameHelper.SanitizePath(item.Author.Nickname);
|
||||
var folder = Path.Combine(cookie.UpSavePath, authorName);
|
||||
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
||||
if(config.UperSaveTogether)
|
||||
{
|
||||
return folder;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sampleName = TikTokFileNameHelper.GenerateFileName(item.Desc, item.AwemeId);
|
||||
var (existingName, _) = _douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
|
||||
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
|
||||
return Path.Combine(folder, fileNameFolder);
|
||||
}
|
||||
}
|
||||
|
||||
protected override string GetVideoFileName(DouyinUserCookie cookie, Aweme item)
|
||||
{
|
||||
var bitRate = item.Video.BitRate.FirstOrDefault();
|
||||
var config = _commonService.GetConfig();
|
||||
var fileName = string.Empty;
|
||||
if (config?.UperUseViedoTitle ?? false)
|
||||
{
|
||||
var sampleName = TikTokFileNameHelper.GenerateFileName(item.Desc, item.AwemeId);
|
||||
var (existingName, _) = _douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
|
||||
fileName= string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{bitRate.Format}" : $"{existingName}.{bitRate.Format}";
|
||||
}
|
||||
else
|
||||
{
|
||||
fileName = $"{item.AwemeId}.{bitRate.Format}";
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="item"></param>
|
||||
/// <param name="config"></param>
|
||||
/// <param name="imageType"></param>
|
||||
/// <returns></returns>
|
||||
protected override string GetNfoFileName(DouyinUserCookie cookie, Aweme item, AppConfig config, string imageType)
|
||||
{
|
||||
if (config.UperSaveTogether)
|
||||
{
|
||||
var videoFileName = GetVideoFileName(cookie, item);
|
||||
return $"{Path.GetFileNameWithoutExtension(videoFileName)}{imageType}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return base.GetNfoFileName(cookie,item,config,imageType);
|
||||
}
|
||||
}
|
||||
|
||||
protected override string GetAuthorAvatarBasePath(DouyinUserCookie cookie)
|
||||
{
|
||||
return Path.Combine(cookie.UpSavePath, "author");
|
||||
}
|
||||
|
||||
protected override async Task HandleSyncCompletion(DouyinUserCookie cookie, int syncCount)
|
||||
{
|
||||
if (syncCount > 0)
|
||||
{
|
||||
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
|
||||
cookie.UperSyncd = 1;
|
||||
await _dyCookieService.UpdateAsync(cookie);
|
||||
}
|
||||
else
|
||||
{
|
||||
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次没有查询到新的视频");
|
||||
}
|
||||
}
|
||||
|
||||
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinUserCookie cookie, Aweme item)
|
||||
{
|
||||
var config = _commonService.GetConfig();
|
||||
string simplifiedTitle = string.Empty;
|
||||
|
||||
if (config?.UperUseViedoTitle ?? false)
|
||||
{
|
||||
simplifiedTitle = TikTokFileNameHelper.GenerateFileName(item.Desc, item.AwemeId);
|
||||
}
|
||||
|
||||
return new VideoEntityDifferences
|
||||
{
|
||||
VideoType = VideoTypeEnum.UperPost,
|
||||
VideoTitleSimplify = simplifiedTitle
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using ClockSnowFlake;
|
||||
using dy.image;
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.service;
|
||||
@@ -22,44 +21,44 @@ namespace dy.net.job
|
||||
/// 提供了通用的同步逻辑,如Cookie处理、视频下载、数据存储等
|
||||
/// </summary>
|
||||
[DisallowConcurrentExecution] // 禁止并发执行,确保同一时间只有一个实例在运行
|
||||
public abstract class DouyinBaseSyncJob : IJob
|
||||
public abstract class DouyinBasicSyncJob : IJob
|
||||
{
|
||||
#region 受保护字段
|
||||
|
||||
/// <summary>
|
||||
/// 抖音Cookie服务,用于获取和管理用户Cookie
|
||||
/// </summary>
|
||||
protected readonly DouyinCookieService _dyCookieService;
|
||||
protected readonly DouyinCookieService douyinCookieService;
|
||||
|
||||
/// <summary>
|
||||
/// 抖音HTTP客户端服务,用于发送HTTP请求
|
||||
/// </summary>
|
||||
protected readonly DouyinHttpClientService _douyinService;
|
||||
protected readonly DouyinHttpClientService douyinHttpClientService;
|
||||
|
||||
/// <summary>
|
||||
/// 抖音视频服务,用于视频信息的数据库操作
|
||||
/// </summary>
|
||||
protected readonly DouyinVideoService _douyinVideoService;
|
||||
protected readonly DouyinVideoService douyinVideoService;
|
||||
|
||||
/// <summary>
|
||||
/// 抖音通用服务,用于获取应用配置等
|
||||
/// </summary>
|
||||
protected readonly DouyinCommonService _commonService;
|
||||
protected readonly DouyinCommonService douyinCommonService;
|
||||
/// <summary>
|
||||
/// 抖音关注列表
|
||||
/// </summary>
|
||||
private readonly DouyinFollowService douyinFollowService;
|
||||
|
||||
/// <summary>
|
||||
/// 图文合成视频
|
||||
/// </summary>
|
||||
private readonly DouyinMergeVideoService douyinMergeVideoService;
|
||||
/// <summary>
|
||||
/// 随机数生成器,用于生成随机延迟,模拟人类操作
|
||||
/// </summary>
|
||||
protected readonly Random _random = new Random();
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供器,用于获取其他服务实例
|
||||
/// </summary>
|
||||
protected readonly IServiceProvider _serviceProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Web主机环境,用于获取应用程序路径等信息
|
||||
/// </summary>
|
||||
protected readonly IWebHostEnvironment _environment;
|
||||
|
||||
/// <summary>
|
||||
/// 每页请求的视频数量,可通过配置文件修改
|
||||
@@ -90,28 +89,28 @@ namespace dy.net.job
|
||||
#region 构造函数
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 <see cref="DouyinBaseSyncJob"/> 类的新实例
|
||||
/// 初始化 <see cref="DouyinBasicSyncJob"/> 类的新实例
|
||||
/// </summary>
|
||||
/// <param name="dyCookieService">抖音Cookie服务</param>
|
||||
/// <param name="dyHttpClientService">抖音HTTP客户端服务</param>
|
||||
/// <param name="dyCollectVideoService">抖音视频服务</param>
|
||||
/// <param name="commonService">抖音通用服务</param>
|
||||
/// <param name="serviceProvider">服务提供器</param>
|
||||
/// <param name="webHostEnvironment">Web主机环境</param>
|
||||
protected DouyinBaseSyncJob(
|
||||
DouyinCookieService dyCookieService,
|
||||
DouyinHttpClientService dyHttpClientService,
|
||||
DouyinVideoService dyCollectVideoService,
|
||||
DouyinCommonService commonService,
|
||||
IServiceProvider serviceProvider,
|
||||
IWebHostEnvironment webHostEnvironment)
|
||||
/// <param name="douyinCookieService">抖音Cookie服务</param>
|
||||
/// <param name="douyinHttpClientService">抖音HTTP客户端服务</param>
|
||||
/// <param name="douyinVideoService">抖音视频服务</param>
|
||||
/// <param name="douyinCommonService">抖音通用服务</param>
|
||||
/// <param name="douyinFollowService">抖音关注的</param>
|
||||
/// <param name="douyinMergeVideoService">视频合成</param>
|
||||
protected DouyinBasicSyncJob(
|
||||
DouyinCookieService douyinCookieService,
|
||||
DouyinHttpClientService douyinHttpClientService,
|
||||
DouyinVideoService douyinVideoService,
|
||||
DouyinCommonService douyinCommonService,
|
||||
DouyinFollowService douyinFollowService,
|
||||
DouyinMergeVideoService douyinMergeVideoService)
|
||||
{
|
||||
_dyCookieService = dyCookieService ?? throw new ArgumentNullException(nameof(dyCookieService));
|
||||
_douyinService = dyHttpClientService ?? throw new ArgumentNullException(nameof(dyHttpClientService));
|
||||
_douyinVideoService = dyCollectVideoService ?? throw new ArgumentNullException(nameof(dyCollectVideoService));
|
||||
_commonService = commonService ?? throw new ArgumentNullException(nameof(commonService));
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_environment = webHostEnvironment ?? throw new ArgumentNullException(nameof(webHostEnvironment));
|
||||
this.douyinCookieService = douyinCookieService ?? throw new ArgumentNullException(nameof(douyinCookieService));
|
||||
this.douyinHttpClientService = douyinHttpClientService ?? throw new ArgumentNullException(nameof(douyinHttpClientService));
|
||||
this.douyinVideoService = douyinVideoService ?? throw new ArgumentNullException(nameof(douyinVideoService));
|
||||
this.douyinCommonService = douyinCommonService ?? throw new ArgumentNullException(nameof(douyinCommonService));
|
||||
this.douyinFollowService = douyinFollowService;
|
||||
this.douyinMergeVideoService = douyinMergeVideoService;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -127,15 +126,13 @@ namespace dy.net.job
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
// 1. 获取应用配置
|
||||
var config = _commonService.GetConfig();
|
||||
var config = douyinCommonService.GetConfig();
|
||||
if (config == null)
|
||||
{
|
||||
Log.Debug($"{JobType}-未获取到系统配置,任务终止!!!");
|
||||
return;
|
||||
}
|
||||
|
||||
//将配置项打印日志
|
||||
//config.PrintAsTable();
|
||||
|
||||
// 2. 从配置中获取每页请求数量
|
||||
if (config.BatchCount > 0)
|
||||
@@ -169,8 +166,7 @@ namespace dy.net.job
|
||||
#region 受保护方法
|
||||
|
||||
/// <summary>
|
||||
/// 在处理Cookie之前执行的预处理操作
|
||||
/// 子类可以重写此方法来实现特定的预处理逻辑
|
||||
/// 在处理Cookie之前执行的预处理操作-AOP
|
||||
/// </summary>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
protected virtual Task BeforeProcessCookies() => Task.CompletedTask;
|
||||
@@ -180,15 +176,26 @@ namespace dy.net.job
|
||||
/// 子类必须实现此方法,根据具体任务类型筛选有效的Cookie
|
||||
/// </summary>
|
||||
/// <returns>有效的Cookie列表</returns>
|
||||
protected abstract Task<List<DouyinUserCookie>> GetValidCookies();
|
||||
protected abstract Task<List<DouyinCookie>> GetValidCookies();
|
||||
|
||||
/// <summary>
|
||||
/// 获取关注列表
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
protected virtual Task<List<DouyinFollowed>> GetFollows()
|
||||
{
|
||||
return Task.FromResult(new List<DouyinFollowed>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定的Cookie是否有效
|
||||
/// 子类必须实现此方法,提供具体的Cookie有效性检查逻辑
|
||||
/// </summary>
|
||||
/// <param name="cookie">要检查的Cookie</param>
|
||||
/// <returns>如果Cookie有效,则为true;否则为false</returns>
|
||||
protected abstract bool IsCookieValid(DouyinUserCookie cookie);
|
||||
protected virtual bool IsCookieValid(DouyinCookie cookie)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(cookie.Cookies);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据Cookie和游标获取视频数据
|
||||
@@ -196,8 +203,9 @@ namespace dy.net.job
|
||||
/// </summary>
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="cursor">分页游标,用于获取下一页数据</param>
|
||||
/// <param name="uperUid">关注的人</param>
|
||||
/// <returns>视频信息对象,包含视频列表和分页信息</returns>
|
||||
protected abstract Task<DouyinVideoInfo> FetchVideoData(DouyinUserCookie cookie, string cursor);
|
||||
protected abstract Task<DouyinVideoInfo> FetchVideoData(DouyinCookie cookie, string cursor, string uperUid = "");
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否应该继续同步下一页数据
|
||||
@@ -205,8 +213,9 @@ namespace dy.net.job
|
||||
/// </summary>
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="data">当前获取到的视频数据</param>
|
||||
/// <param name="followed">关注博主</param>
|
||||
/// <returns>如果应该继续同步,则为true;否则为false</returns>
|
||||
protected abstract bool ShouldContinueSync(DouyinUserCookie cookie, DouyinVideoInfo data);
|
||||
protected abstract bool ShouldContinueSync(DouyinCookie cookie, DouyinVideoInfo data, DouyinFollowed followed);
|
||||
|
||||
/// <summary>
|
||||
/// 获取下一页数据的游标
|
||||
@@ -222,28 +231,31 @@ namespace dy.net.job
|
||||
/// </summary>
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <param name="tag1">视频标签1</param>
|
||||
/// <param name="tag2">视频标签2</param>
|
||||
/// <param name="followed">关注用户</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <returns>创建的视频保存文件夹路径</returns>
|
||||
protected abstract string CreateSaveFolder(DouyinUserCookie cookie, Aweme item, string tag1, string tag2, AppConfig config);
|
||||
protected abstract string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed);
|
||||
|
||||
/// <summary>
|
||||
/// 获取视频文件名
|
||||
/// 子类必须实现此方法,根据具体的命名规则生成文件名
|
||||
/// 获取视频文件名,默认就用id作文件名
|
||||
/// </summary>
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <param name="config">配置信息</param>
|
||||
/// <returns>生成的视频文件名</returns>
|
||||
protected abstract string GetVideoFileName(DouyinUserCookie cookie, Aweme item);
|
||||
|
||||
protected virtual string GetVideoFileName(DouyinCookie cookie, Aweme item, AppConfig config)
|
||||
{
|
||||
if (item.Video != null && item.Video.BitRate != null)
|
||||
return $"{item.AwemeId}.{item.Video.BitRate.FirstOrDefault().Format}";
|
||||
return $"{item.AwemeId}.mp4";
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取作者头像保存的基础路径
|
||||
/// 子类必须实现此方法,指定头像的存储位置
|
||||
/// </summary>
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <returns>作者头像保存的基础路径</returns>
|
||||
protected abstract string GetAuthorAvatarBasePath(DouyinUserCookie cookie);
|
||||
protected abstract string GetAuthorAvatarBasePath(DouyinCookie cookie);
|
||||
|
||||
/// <summary>
|
||||
/// 处理同步完成后的操作
|
||||
@@ -252,7 +264,7 @@ namespace dy.net.job
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="syncCount">本次同步成功的视频数量</param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
protected abstract Task HandleSyncCompletion(DouyinUserCookie cookie, int syncCount);
|
||||
protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount);
|
||||
|
||||
/// <summary>
|
||||
/// 获取视频实体的差异信息
|
||||
@@ -261,7 +273,7 @@ namespace dy.net.job
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <returns>视频实体的差异信息,包含视频类型和简化标题</returns>
|
||||
protected abstract VideoEntityDifferences GetVideoEntityDifferences(DouyinUserCookie cookie, Aweme item);
|
||||
protected abstract VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item);
|
||||
|
||||
/// <summary>
|
||||
/// 获取NFO文件中的图片(如海报)文件名
|
||||
@@ -272,7 +284,7 @@ namespace dy.net.job
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <param name="fileName">原文件名称(如poster.jpg)</param>
|
||||
/// <returns>封面图片的文件名</returns>
|
||||
protected virtual string GetNfoFileName(DouyinUserCookie cookie, Aweme item, AppConfig config, string fileName)
|
||||
protected virtual string GetNfoFileName(DouyinCookie cookie, Aweme item, AppConfig config, string fileName)
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
@@ -284,7 +296,7 @@ namespace dy.net.job
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
protected async Task ProcessSyncUserCookie(DouyinUserCookie cookie, AppConfig config)
|
||||
protected async Task ProcessSyncUserCookie(DouyinCookie cookie, AppConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -294,44 +306,51 @@ namespace dy.net.job
|
||||
Log.Debug($"{JobType}-Cookie[{cookie.UserName}]无效,任务终止!!!");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Debug($"{JobType}- Cookie-[{cookie.UserName}]开始同步...");
|
||||
|
||||
int syncCount = 0; // 本次同步成功的视频数量
|
||||
string cursor = "0"; // 初始游标
|
||||
bool hasMore = true; // 是否还有更多数据
|
||||
|
||||
// 循环获取视频数据
|
||||
while (hasMore)
|
||||
//up主上传视频特殊处理
|
||||
if (JobType == SystemStaticUtil.DY_FOLLOWEDS)
|
||||
{
|
||||
// 获取视频数据
|
||||
var data = await FetchVideoData(cookie, cursor);
|
||||
if (data == null)
|
||||
int syncCount = 0; // 本次同步成功的视频数量
|
||||
string cursor = "0"; // 初始游标
|
||||
bool hasMore = true; // 是否还有更多数据
|
||||
var follows = await douyinFollowService.GetSyncFollows(cookie.MyUserId);
|
||||
|
||||
|
||||
//var ups = JsonConvert.DeserializeObject<List<DouyinUpSecUserIdDto>>(cookie.UpSecUserIds);
|
||||
var firstUp = follows?.Where(x => !string.IsNullOrWhiteSpace(x.SecUid)).FirstOrDefault();
|
||||
if (firstUp == null)
|
||||
{
|
||||
Log.Debug($"{JobType}-Cookie[{cookie.UserName}]读取数据失败!!!");
|
||||
break;
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(firstUp.SecUid))
|
||||
{
|
||||
Log.Debug($"{JobType}-Cookie[{firstUp.UperName}]无效,没有sec_userid,任务终止!!!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
hasMore = ShouldContinueSync(cookie, data);
|
||||
// 获取下一页游标
|
||||
cursor = GetNextCursor(data);
|
||||
|
||||
// 如果没有视频数据,退出循环
|
||||
if (data.AwemeList == null || !data.AwemeList.Any())
|
||||
break;
|
||||
foreach (var item in follows)
|
||||
{
|
||||
cursor = "0"; // 初始游标
|
||||
await GetViedos(cookie, config, syncCount, cursor, hasMore, item);
|
||||
hasMore = true;
|
||||
// 处理同步完成后的操作
|
||||
await HandleSyncCompletion(cookie, syncCount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int syncCount = 0; // 本次同步成功的视频数量
|
||||
string cursor = "0"; // 初始游标
|
||||
bool hasMore = true; // 是否还有更多数据
|
||||
(syncCount, cursor, hasMore) = await GetViedos(cookie, config, syncCount, cursor, hasMore);
|
||||
|
||||
// 处理视频列表
|
||||
var videos = await ProcessVideoList(cookie, data, config);
|
||||
// 保存视频信息到数据库
|
||||
syncCount += await SaveVideos(videos);
|
||||
|
||||
// 随机延迟,模拟人类操作,避免请求过快
|
||||
await Task.Delay(_random.Next(5, 10) * 1000);
|
||||
// 处理同步完成后的操作
|
||||
await HandleSyncCompletion(cookie, syncCount);
|
||||
}
|
||||
|
||||
// 处理同步完成后的操作
|
||||
await HandleSyncCompletion(cookie, syncCount);
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -339,6 +358,40 @@ namespace dy.net.job
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int syncCount, string cursor, bool hasMore)> GetViedos(DouyinCookie cookie, AppConfig config, int syncCount, string cursor, bool hasMore, DouyinFollowed followed = null)
|
||||
{
|
||||
// 循环获取视频数据
|
||||
while (hasMore)
|
||||
{
|
||||
// 获取视频数据
|
||||
var data = await FetchVideoData(cookie, cursor, followed == null ? "" : followed?.SecUid);
|
||||
if (data == null)
|
||||
{
|
||||
Log.Debug($"{JobType}-Cookie[{cookie.UserName}]读取数据失败!!!");
|
||||
break;
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
hasMore = ShouldContinueSync(cookie, data, followed);
|
||||
// 获取下一页游标
|
||||
cursor = GetNextCursor(data);
|
||||
|
||||
// 如果没有视频数据,退出循环
|
||||
if (data.AwemeList == null || !data.AwemeList.Any())
|
||||
break;
|
||||
|
||||
// 处理视频列表
|
||||
var videos = await ProcessVideoList(cookie, data, config, followed);
|
||||
// 保存视频信息到数据库
|
||||
syncCount += await SaveVideos(videos);
|
||||
|
||||
// 随机延迟,模拟人类操作,避免请求过快
|
||||
await Task.Delay(_random.Next(5, 10) * 1000);
|
||||
}
|
||||
|
||||
return (syncCount, cursor, hasMore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理视频列表
|
||||
/// 遍历视频列表,分别处理每个视频和图片集
|
||||
@@ -346,23 +399,45 @@ namespace dy.net.job
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="data">视频信息对象</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <param name="followed">关注的</param>
|
||||
/// <returns>处理后的视频实体列表</returns>
|
||||
protected async Task<List<DouyinVideo>> ProcessVideoList(DouyinUserCookie cookie, DouyinVideoInfo data, AppConfig config)
|
||||
protected async Task<List<DouyinVideo>> ProcessVideoList(DouyinCookie cookie, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed = null)
|
||||
{
|
||||
var videos = new List<DouyinVideo>();
|
||||
foreach (var item in data.AwemeList)
|
||||
{
|
||||
//去重,检查视频是否已存在
|
||||
if (config.AutoDistinct)
|
||||
{
|
||||
var exitVideo = await douyinVideoService.GetByAwemeId(item.AwemeId);
|
||||
if (exitVideo != null)
|
||||
{
|
||||
if (File.Exists(exitVideo.VideoSavePath))
|
||||
{
|
||||
continue;// 已存在则跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId);
|
||||
if (uper != null)
|
||||
{
|
||||
if (uper.FullSync)
|
||||
{
|
||||
followed ??= uper;
|
||||
}
|
||||
}
|
||||
// 处理单个视频
|
||||
var video = await ProcessSingleVideo(cookie, item, data, config);
|
||||
var video = await ProcessSingleVideo(cookie, item, data, config, followed);
|
||||
if (video != null)
|
||||
videos.Add(video);
|
||||
|
||||
// 如果配置了下载图片视频,则处理图片集并合成视频
|
||||
if (_downImageVideo)
|
||||
{
|
||||
if(config.DownImageVideo||config.DownMp3||config.DownImage)
|
||||
if (config.DownImageVideo || config.DownMp3 || config.DownImage)
|
||||
{
|
||||
var mergevideo = await ProcessImageSetAndMergeToVideo(cookie, item, data, config);
|
||||
var mergevideo = await ProcessImageSetAndMergeToVideo(cookie, item, data, config, followed);
|
||||
if (mergevideo != null)
|
||||
videos.Add(mergevideo);
|
||||
}
|
||||
@@ -379,8 +454,9 @@ namespace dy.net.job
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <param name="data">视频信息对象</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <param name="followed">关注</param>
|
||||
/// <returns>处理后的视频实体,如果处理失败则为null</returns>
|
||||
protected async Task<DouyinVideo> ProcessSingleVideo(DouyinUserCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config)
|
||||
protected async Task<DouyinVideo> ProcessSingleVideo(DouyinCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed = null)
|
||||
{
|
||||
// 检查视频数据是否有效
|
||||
if (!IsAwemeValid(item)) return null;
|
||||
@@ -396,27 +472,27 @@ namespace dy.net.job
|
||||
// 获取视频标签
|
||||
var (tag1, tag2, tag3) = GetVideoTags(item);
|
||||
// 创建保存文件夹
|
||||
var saveFolder = CreateSaveFolder(cookie, item, tag1, tag2, config);
|
||||
var saveFolder = CreateSaveFolder(cookie, item, config, followed);
|
||||
// 获取视频文件名
|
||||
var fileName = GetVideoFileName(cookie, item);
|
||||
var fileName = GetVideoFileName(cookie, item, config);
|
||||
// 拼接视频保存路径
|
||||
var savePath = Path.Combine(saveFolder, fileName);
|
||||
|
||||
// 如果文件已存在,跳过
|
||||
if (File.Exists(savePath)) return null;
|
||||
|
||||
Log.Debug($"{JobType}-视频[{TikTokFileNameHelper.SanitizePath(item.Desc)}]开始下载...");
|
||||
Log.Debug($"{JobType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]开始下载...");
|
||||
// 随机延迟,模拟人类操作
|
||||
await Task.Delay(_random.Next(1, 4) * 1000);
|
||||
// 下载视频
|
||||
if (!await _douyinService.DownloadAsync(videoUrl, savePath, cookie.Cookies))
|
||||
if (!await douyinHttpClientService.DownloadAsync(videoUrl, savePath, cookie.Cookies))
|
||||
{
|
||||
Log.Error($"{JobType}-{item?.Author?.Nickname??""}-视频[{TikTokFileNameHelper.SanitizePath(item.Desc)}]下载失败!!!");
|
||||
Log.Error($"{JobType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]下载失败!!!");
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"{JobType}-{item?.Author?.Nickname ?? ""}-视频[{TikTokFileNameHelper.SanitizePath(item.Desc)}]下载完成.");
|
||||
Log.Debug($"{JobType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]下载完成.");
|
||||
}
|
||||
|
||||
// 下载视频封面
|
||||
@@ -424,7 +500,7 @@ namespace dy.net.job
|
||||
// 下载作者头像
|
||||
var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item);
|
||||
// 生成NFO文件
|
||||
await GenerateNfoFile(saveFolder, item, avatarSavePath, avatarUrl, cookie, config);
|
||||
await GenerateNfoFile(saveFolder, item, avatarUrl, cookie, config);
|
||||
// 创建视频实体
|
||||
return CreateVideoEntity(cookie, item, v, savePath, saveFolder, tag1, tag2, tag3, avatarSavePath, avatarUrl, data);
|
||||
}
|
||||
@@ -437,8 +513,9 @@ namespace dy.net.job
|
||||
/// <param name="item">视频信息(包含图片集)</param>
|
||||
/// <param name="data">视频信息对象</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <param name="followed">应用配置</param>
|
||||
/// <returns>合成后的视频实体,如果处理失败则为null</returns>
|
||||
protected async Task<DouyinVideo> ProcessImageSetAndMergeToVideo(DouyinUserCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config)
|
||||
protected async Task<DouyinVideo> ProcessImageSetAndMergeToVideo(DouyinCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -455,26 +532,28 @@ namespace dy.net.job
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查图片保存路径是否配置
|
||||
if (string.IsNullOrWhiteSpace(cookie.ImgSavePath))
|
||||
{
|
||||
Log.Error($"{JobType}-图文视频同步-没有配置图片存储路径,任务终止!!!");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取图片合成视频服务
|
||||
var imageService = _serviceProvider.GetService<ImageMergeToVideoService>();
|
||||
if (imageService == null)
|
||||
{
|
||||
Log.Error($"{JobType} -图文视频同步异常,请联系作者!!!");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建图片保存文件夹
|
||||
var fileNamefolder = Path.Combine(cookie.ImgSavePath, TikTokFileNameHelper.GenerateFileName(item.Desc, item.AwemeId));
|
||||
var fileNamefolder = string.Empty;
|
||||
|
||||
if (config.ImageViedoSaveAlone)
|
||||
{
|
||||
// 检查图片保存路径是否配置
|
||||
if (string.IsNullOrWhiteSpace(cookie.ImgSavePath))
|
||||
{
|
||||
Log.Error($"{JobType}-图文视频同步-没有配置图片存储路径,任务终止!!!");
|
||||
return null;
|
||||
}
|
||||
fileNamefolder = Path.Combine(cookie.ImgSavePath, DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId));
|
||||
}
|
||||
else
|
||||
{
|
||||
fileNamefolder = CreateSaveFolder(cookie, item, config, followed);
|
||||
}
|
||||
if (!Directory.Exists(fileNamefolder)) Directory.CreateDirectory(fileNamefolder);
|
||||
|
||||
var fileName = GetVideoFileName(cookie, item, config);
|
||||
// 合成视频的保存路径
|
||||
var savePath = Path.Combine(fileNamefolder, $"{item.AwemeId}.mp4");
|
||||
var savePath = Path.Combine(fileNamefolder, fileName);
|
||||
|
||||
// 如果文件已存在,返回null
|
||||
if (File.Exists(savePath)) return null;
|
||||
@@ -482,6 +561,10 @@ namespace dy.net.job
|
||||
// 获取音乐URL
|
||||
var mp3Url = item.Music?.PlayUrl?.UrlList?.FirstOrDefault();
|
||||
|
||||
var firstImage = item.Images.FirstOrDefault();
|
||||
int height = firstImage.Height;
|
||||
int width = firstImage.Width;
|
||||
|
||||
// 准备合成视频的请求参数
|
||||
var reqParams = new MediaMergeRequest
|
||||
{
|
||||
@@ -490,15 +573,15 @@ namespace dy.net.job
|
||||
VideoFps = 30, // 视频帧率
|
||||
AudioUrls = string.IsNullOrWhiteSpace(mp3Url) ? new List<string>() : new List<string> { mp3Url }, // 音频URL列表
|
||||
ImageUrls = imageUrls, // 图片URL列表
|
||||
VideoWidth = 1080, // 视频宽度
|
||||
VideoHeight = 1920, // 视频高度
|
||||
VideoWidth = width > 0 ? width : 1080, // 视频宽度
|
||||
VideoHeight = height > 0 ? height : 1920, // 视频高度
|
||||
};
|
||||
|
||||
// 执行图片合成视频操作
|
||||
var mergeResult = await imageService.MergeToVideo(AppContext.BaseDirectory, reqParams, savePath, fileNamefolder,config.DownImageVideo,config.DownImage,config.DownMp3);
|
||||
var mergeResult = await douyinMergeVideoService.MergeToVideo(cookie.Cookies, AppContext.BaseDirectory, reqParams, savePath, fileNamefolder, config.DownImageVideo, config.DownImage, config.DownMp3);
|
||||
if (!mergeResult)
|
||||
{
|
||||
Log.Error($"{JobType}-图文视频-[{TikTokFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!");
|
||||
Log.Error($"{JobType}-图文视频-[{DouyinFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -507,7 +590,7 @@ namespace dy.net.job
|
||||
// 检查合成后的视频文件是否有效
|
||||
if (!File.Exists(savePath) || new FileInfo(savePath).Length <= 0)
|
||||
{
|
||||
Log.Error($"{JobType}-图文视频-[{TikTokFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!");
|
||||
Log.Error($"{JobType}-图文视频-[{DouyinFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!");
|
||||
// 清理无效的文件和文件夹
|
||||
if (Directory.Exists(fileNamefolder))
|
||||
{
|
||||
@@ -522,12 +605,12 @@ namespace dy.net.job
|
||||
{
|
||||
}
|
||||
|
||||
// 下载视频封面(使用第一张图片作为封面)
|
||||
await DownVideoCover(imageUrls.FirstOrDefault(), fileNamefolder, cookie, item, config);
|
||||
// 下载视频封面(使用第一张图片作为封面)
|
||||
await DownVideoCover(imageUrls.FirstOrDefault(), fileNamefolder, cookie, item, config);
|
||||
// 下载作者头像
|
||||
var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item);
|
||||
// 生成NFO文件
|
||||
await GenerateNfoFile(fileNamefolder, item, avatarSavePath, avatarUrl, cookie, config);
|
||||
await GenerateNfoFile(fileNamefolder, item, avatarUrl, cookie, config);
|
||||
|
||||
// 获取视频标签
|
||||
var (tag1, tag2, tag3) = GetVideoTags(item);
|
||||
@@ -572,7 +655,26 @@ namespace dy.net.job
|
||||
if (!videos.Any()) return 0;
|
||||
try
|
||||
{
|
||||
await _douyinVideoService.batchInsert(videos);
|
||||
await douyinVideoService.batchInsert(videos);
|
||||
|
||||
var redowns = await douyinCommonService.GetAllRedown();
|
||||
if (redowns != null && redowns.Any())
|
||||
{
|
||||
//找出viedos和redowns重复的
|
||||
var duplicateVideos = videos.Where(v => redowns.Any(r => r.ViedoId == v.AwemeId)).ToList();
|
||||
if (duplicateVideos != null && duplicateVideos.Any())
|
||||
{
|
||||
var duplicateVideosIds = duplicateVideos.Select(x => x.AwemeId).ToList();
|
||||
var downeds = redowns.Where(x => duplicateVideosIds.Contains(x.ViedoId))?.ToList();
|
||||
foreach (var item in downeds)
|
||||
{
|
||||
item.Status = 1;
|
||||
var v = videos.FirstOrDefault(x => x.AwemeId == item.ViedoId);
|
||||
Log.Debug($"{JobType}-重新下载成功:-{v.VideoTitle}");
|
||||
}
|
||||
await douyinCommonService.UpdateRedownStatus(downeds);
|
||||
}
|
||||
}
|
||||
return videos.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -591,12 +693,11 @@ namespace dy.net.job
|
||||
/// <param name="saveFolder">NFO文件的保存文件夹</param>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <param name="avatarSavePath">作者头像保存路径</param>
|
||||
/// <param name="avatarUrl">作者头像URL</param>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="config"></param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
protected async Task GenerateNfoFile(string saveFolder, Aweme item,
|
||||
string avatarSavePath, string avatarUrl, DouyinUserCookie cookie, AppConfig config)
|
||||
string avatarSavePath, DouyinCookie cookie, AppConfig config)
|
||||
{
|
||||
// 异步生成NFO文件,避免阻塞主线程
|
||||
await Task.Run(() =>
|
||||
@@ -634,7 +735,7 @@ namespace dy.net.job
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
protected async Task DownVideoCover(Aweme item, string saveFolder, DouyinUserCookie cookie, AppConfig config)
|
||||
protected async Task DownVideoCover(Aweme item, string saveFolder, DouyinCookie cookie, AppConfig config)
|
||||
{
|
||||
var coverUrl = item.Video.Cover.UrlList?.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(coverUrl)) return;
|
||||
@@ -648,7 +749,7 @@ namespace dy.net.job
|
||||
/// <param name="cookie">用户Cookie</param>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <returns>一个元组,包含头像保存路径和头像URL</returns>
|
||||
protected async Task<(string savePath, string url)> DownAuthorAvatar(DouyinUserCookie cookie, Aweme item)
|
||||
protected async Task<(string savePath, string url)> DownAuthorAvatar(DouyinCookie cookie, Aweme item)
|
||||
{
|
||||
if (item.Author == null) return (null, null);
|
||||
// 优先获取高清头像
|
||||
@@ -663,7 +764,7 @@ namespace dy.net.job
|
||||
// 如果头像文件不存在,则下载
|
||||
if (!File.Exists(avatarSavePath))
|
||||
{
|
||||
await _douyinService.DownloadAsync(avatarUrl, avatarSavePath, cookie.Cookies);
|
||||
await douyinHttpClientService.DownloadAsync(avatarUrl, avatarSavePath, cookie.Cookies);
|
||||
}
|
||||
return (avatarSavePath, avatarUrl);
|
||||
}
|
||||
@@ -686,7 +787,7 @@ namespace dy.net.job
|
||||
/// </summary>
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <returns>一个元组,包含三个级别的视频标签</returns>
|
||||
private (string tag1, string tag2, string tag3) GetVideoTags(Aweme item)
|
||||
protected (string tag1, string tag2, string tag3) GetVideoTags(Aweme item)
|
||||
{
|
||||
var tags = item.VideoTags;
|
||||
return (
|
||||
@@ -758,7 +859,7 @@ namespace dy.net.job
|
||||
/// <param name="item">视频信息</param>
|
||||
/// <param name="config">应用配置</param>
|
||||
/// <returns>一个表示异步操作的任务</returns>
|
||||
private async Task DownVideoCover(string coverUrl, string saveFolder, DouyinUserCookie cookie, Aweme item, AppConfig config)
|
||||
private async Task DownVideoCover(string coverUrl, string saveFolder, DouyinCookie cookie, Aweme item, AppConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(coverUrl)) return;
|
||||
// 获取封面图片文件名
|
||||
@@ -768,14 +869,7 @@ namespace dy.net.job
|
||||
// 如果封面文件不存在,则下载
|
||||
if (!File.Exists(coverSavePath))
|
||||
{
|
||||
var downRes = await _douyinService.DownloadAsync(coverUrl, coverSavePath, cookie.Cookies);
|
||||
//if (downRes)
|
||||
//{
|
||||
// // 复制封面图片为fanart.jpg
|
||||
// var fanartImgName = GetNfoFileName(cookie, item, config, "fanart.jpg");
|
||||
// var copyPath = Path.Combine(saveFolder, fanartImgName);
|
||||
// File.Copy(coverSavePath, copyPath, true);
|
||||
//}
|
||||
var downRes = await douyinHttpClientService.DownloadAsync(coverUrl, coverSavePath, cookie.Cookies);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,7 +890,7 @@ namespace dy.net.job
|
||||
/// <param name="data">视频信息对象</param>
|
||||
/// <returns>创建的视频实体对象</returns>
|
||||
private DouyinVideo CreateVideoEntity(
|
||||
DouyinUserCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder,
|
||||
DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder,
|
||||
string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data)
|
||||
{
|
||||
var diffs = GetVideoEntityDifferences(cookie, item);
|
||||
@@ -0,0 +1,81 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.service;
|
||||
using Quartz;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace dy.net.job
|
||||
{
|
||||
[DisallowConcurrentExecution] // 禁止并发执行,确保同一时间只有一个实例在运行
|
||||
public class DouyinFollowedUsersSyncJob : IJob
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 抖音Cookie服务,用于获取和管理用户Cookie
|
||||
/// </summary>
|
||||
protected readonly DouyinCookieService _dyCookieService;
|
||||
|
||||
/// <summary>
|
||||
/// 抖音HTTP客户端服务,用于发送HTTP请求
|
||||
/// </summary>
|
||||
protected readonly DouyinHttpClientService _douyinService;
|
||||
|
||||
/// <summary>
|
||||
/// 抖音关注服务,用于管理关注数据
|
||||
/// </summary>
|
||||
protected readonly DouyinFollowService _followService;
|
||||
|
||||
public DouyinFollowedUsersSyncJob(DouyinCookieService dyCookieService, DouyinHttpClientService douyinService, DouyinFollowService followService)
|
||||
{
|
||||
_dyCookieService = dyCookieService;
|
||||
_douyinService = douyinService;
|
||||
_followService = followService;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
var cookies = await _dyCookieService.GetAllOpendAsync();
|
||||
if (cookies != null && cookies.Any())
|
||||
{
|
||||
|
||||
foreach (var ck in cookies)
|
||||
{
|
||||
string count = "20";
|
||||
string offset = "0";
|
||||
bool hasmore = true;
|
||||
int total= 0;
|
||||
List<FollowingsItem> follows = new List<FollowingsItem>();
|
||||
while (hasmore)
|
||||
{
|
||||
var data = await _douyinService.SyncMyFollows(count, offset, ck.SecUserId, ck.Cookies);
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
// 绑定我的用户ID--之前没有这个字段
|
||||
if (string.IsNullOrWhiteSpace(ck.MyUserId))
|
||||
{
|
||||
ck.MyUserId = data.MySelfUserId;
|
||||
await _dyCookieService.UpdateAsync(ck);
|
||||
}
|
||||
total = data.Total;
|
||||
hasmore = data.HasMore;
|
||||
offset = data.Offset.ToString();
|
||||
if (data.Followings != null && data.Followings.Count > 0)
|
||||
{
|
||||
follows.AddRange(data.Followings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (follows.Count > 0)
|
||||
{
|
||||
await _followService.Sync(follows, ck.MyUserId);
|
||||
}
|
||||
|
||||
Serilog.Log.Debug($"当前Cookie-[{ck.UserName}],本次同步关注列表完成,共同步关注{total}人。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using ClockSnowFlake;
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.service;
|
||||
using dy.net.utils;
|
||||
using Newtonsoft.Json;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.net.job
|
||||
{
|
||||
public class DouyinFollowedViedoSyncJob : DouyinBasicSyncJob
|
||||
{
|
||||
public DouyinFollowedViedoSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string JobType => SystemStaticUtil.DY_FOLLOWEDS;
|
||||
|
||||
protected override async Task<List<DouyinCookie>> GetValidCookies()
|
||||
{
|
||||
return await douyinCookieService.GetAllOpendAsync(x => !string.IsNullOrWhiteSpace(x.UpSavePath));
|
||||
}
|
||||
|
||||
protected override bool IsCookieValid(DouyinCookie cookie)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(cookie.Cookies)&&!string.IsNullOrWhiteSpace(cookie.UpSavePath);
|
||||
}
|
||||
|
||||
protected override async Task<DouyinVideoInfo> FetchVideoData(DouyinCookie cookie, string cursor, string uperUid = "")
|
||||
{
|
||||
return await douyinHttpClientService.SyncUpderPostVideos(count, cursor, uperUid, cookie.Cookies);
|
||||
}
|
||||
|
||||
protected override bool ShouldContinueSync(DouyinCookie cookie, DouyinVideoInfo data, DouyinFollowed followed)
|
||||
{
|
||||
return data != null && data.HasMore == 1 && cookie.UperSyncd == 0 && followed.FullSync;
|
||||
}
|
||||
|
||||
protected override string GetNextCursor(DouyinVideoInfo data)
|
||||
{
|
||||
return data?.MaxCursor ?? "0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关注用户特殊处理文件夹存储路径,用户可自定义保存路径
|
||||
/// </summary>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="item"></param>
|
||||
/// <param name="followed"></param>
|
||||
/// <param name="config"></param>
|
||||
/// <returns></returns>
|
||||
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed)
|
||||
{
|
||||
#region 默认使用UP主名称作为文件夹名称,若关注列表中有自定义保存路径则使用自定义路径
|
||||
var authorName = string.IsNullOrWhiteSpace(item.Author?.Nickname) ? "UnknownAuthor" : DouyinFileNameHelper.SanitizePath(item.Author.Nickname);
|
||||
var folder = Path.Combine(cookie.UpSavePath, authorName);
|
||||
if (!string.IsNullOrWhiteSpace(followed.SavePath))
|
||||
{
|
||||
folder = Path.Combine(cookie.UpSavePath, followed.SavePath);
|
||||
}
|
||||
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
|
||||
#endregion
|
||||
|
||||
if (config.UperSaveTogether)
|
||||
{
|
||||
return folder;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sampleName = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId);
|
||||
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
|
||||
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
|
||||
return Path.Combine(folder, fileNameFolder);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 关注的视频,生成文件名称
|
||||
/// </summary>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="item"></param>
|
||||
/// <param name="config"></param>
|
||||
/// <returns></returns>
|
||||
protected override string GetVideoFileName(DouyinCookie cookie, Aweme item,AppConfig config)
|
||||
{
|
||||
|
||||
string Format = "mp4";
|
||||
string FileHash = "";
|
||||
string Height = "";
|
||||
string Width = "";
|
||||
|
||||
if (item.Video != null && item.Video.BitRate != null)
|
||||
{
|
||||
var bitrate = item.Video.BitRate.FirstOrDefault();
|
||||
Format = bitrate.Format;
|
||||
FileHash = bitrate.PlayAddr.FileHash;
|
||||
Height = bitrate.PlayAddr.Height.ToString();
|
||||
Width = bitrate.PlayAddr.Width.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
//图片合成视频,参数要自己写。
|
||||
var image = item.Images?.FirstOrDefault();
|
||||
if(image != null){
|
||||
FileHash = IdGener.GetGuid().ToLower().Replace("-","");//使用随机值,避免重复
|
||||
Height = image.Height.ToString();
|
||||
Width = image.Width.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
string fileName;
|
||||
if (config?.UperUseViedoTitle ?? false)//优先
|
||||
{
|
||||
var sampleName = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId);
|
||||
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
|
||||
fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}";
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.FullFollowedTitleTemplate))
|
||||
{
|
||||
var fullName = VideoTitleGenerator.Generate(config.FullFollowedTitleTemplate, new VideoTitleDataTemplate
|
||||
{
|
||||
FileHash = FileHash,
|
||||
Id = item.AwemeId,
|
||||
ReleaseTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
|
||||
Resolution = $"{Width}×{Height}",
|
||||
VideoTitle = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId)
|
||||
});
|
||||
|
||||
fileName= $"{DouyinFileNameHelper.SanitizePath(fullName)}.{Format}";
|
||||
}
|
||||
else
|
||||
{
|
||||
fileName = $"{item.AwemeId}.{Format}";
|
||||
}
|
||||
}
|
||||
return fileName;
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="item"></param>
|
||||
/// <param name="config"></param>
|
||||
/// <param name="imageType"></param>
|
||||
/// <returns></returns>
|
||||
protected override string GetNfoFileName(DouyinCookie cookie, Aweme item, AppConfig config, string imageType)
|
||||
{
|
||||
if (config.UperSaveTogether)
|
||||
{
|
||||
var videoFileName = GetVideoFileName(cookie, item,config);
|
||||
return $"{Path.GetFileNameWithoutExtension(videoFileName)}{imageType}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return base.GetNfoFileName(cookie, item, config, imageType);
|
||||
}
|
||||
}
|
||||
|
||||
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
|
||||
{
|
||||
return Path.Combine(cookie.UpSavePath, "author");
|
||||
}
|
||||
|
||||
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount)
|
||||
{
|
||||
if (syncCount > 0)
|
||||
{
|
||||
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
|
||||
cookie.UperSyncd = 1;
|
||||
await douyinCookieService.UpdateAsync(cookie);
|
||||
}
|
||||
else
|
||||
{
|
||||
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次没有查询到新的视频");
|
||||
}
|
||||
}
|
||||
|
||||
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item)
|
||||
{
|
||||
var config = douyinCommonService.GetConfig();
|
||||
string simplifiedTitle = string.Empty;
|
||||
|
||||
if (config?.UperUseViedoTitle ?? false)
|
||||
{
|
||||
simplifiedTitle = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId);
|
||||
}
|
||||
|
||||
return new VideoEntityDifferences
|
||||
{
|
||||
VideoType = VideoTypeEnum.UperPost,
|
||||
VideoTitleSimplify = simplifiedTitle
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using SqlSugar;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace dy.net.model
|
||||
{
|
||||
@@ -14,7 +15,7 @@ namespace dy.net.model
|
||||
public string Id { get; set; }
|
||||
|
||||
[SugarColumn(Length =200,IsNullable =true)]
|
||||
public string Cron { get; set; }
|
||||
public int Cron { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每次查询数量
|
||||
@@ -46,10 +47,32 @@ namespace dy.net.model
|
||||
/// 日志保留天数,防止容器日志太多,默认10天
|
||||
/// </summary>
|
||||
public int LogKeepDay { get; set; } = 10;
|
||||
/// <summary>
|
||||
/// 关注的视频标题命名模板{id}{VideoTitle}{SyncTime}{ReleaseTime}{FileHash}{Resolution}{FileSize}
|
||||
/// </summary>
|
||||
public string FollowedTitleTemplate { get; set; }
|
||||
/// <summary>
|
||||
/// 分隔符
|
||||
/// </summary>
|
||||
public string FollowedTitleSeparator { get; set; }
|
||||
/// <summary>
|
||||
/// 完整的标题模板,包含分隔符
|
||||
/// </summary>
|
||||
public string FullFollowedTitleTemplate { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 图文视频是否单独存放,否的话则按原类型存储位置存放,比如收藏夹、喜欢等
|
||||
/// </summary>
|
||||
public bool ImageViedoSaveAlone { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore=true)]
|
||||
public bool DownImageVideoFromEnv { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 自动去重-逻辑是遇到相同ID的视频直接跳过
|
||||
/// </summary>
|
||||
public bool AutoDistinct { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using dy.net.dto;
|
||||
using Newtonsoft.Json;
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.model
|
||||
{
|
||||
[SugarTable(TableName = "dy_cookie")]
|
||||
public class DouyinCookie
|
||||
{
|
||||
|
||||
[SugarColumn(IsPrimaryKey = true)]
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户抖音ID,对应我关注信息里面的myself_user_id
|
||||
/// </summary>
|
||||
public string MyUserId { get; set; }
|
||||
/// <summary>
|
||||
/// 用户描述
|
||||
/// </summary>
|
||||
public string UserName { get; set; }
|
||||
/// <summary>
|
||||
/// 用户抖音Cookie
|
||||
/// </summary>
|
||||
[SugarColumn(Length =-1,IsNullable =true)]
|
||||
public string Cookies { get; set; }
|
||||
/// <summary>
|
||||
/// 存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length =255,IsNullable =true)]
|
||||
public string SavePath { get; set; }
|
||||
|
||||
public int Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同步喜欢的视频需要sec_user_id
|
||||
/// </summary>
|
||||
[SugarColumn(Length =500,IsNullable =true)]
|
||||
public string SecUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 喜欢的视频存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length =500,IsNullable =true)]
|
||||
public string FavSavePath { get; set; }
|
||||
|
||||
///// <summary>
|
||||
///// 最新收藏夹的分页页码
|
||||
///// </summary>
|
||||
//[SugarColumn(Length =500,IsNullable =true)]
|
||||
//public string CollectMaxCursor { get; set; }
|
||||
///// <summary>
|
||||
///// 最新我喜欢的分页页码
|
||||
///// </summary>
|
||||
//[SugarColumn(Length = 500, IsNullable = true)]
|
||||
//public string FavoriteMaxCursor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已经同步过了(就是是否是第一次同步) 0-未同步 1-已同步
|
||||
/// 如果是0,则同步时会获取全部数据;如果是1,则同步时只获取最新的数据(也就是只查一次)
|
||||
/// 接口可以将该值改为0,下次开始同步就会重新获取全部数据
|
||||
/// </summary>
|
||||
public int CollHasSyncd { get; set; }
|
||||
public int FavHasSyncd { get; set; }
|
||||
public int UperSyncd { get; set; }
|
||||
/// <summary>
|
||||
/// 关注的用户sec_user_id列表 json字符串存储
|
||||
/// </summary>
|
||||
//[SugarColumn(Length =-1,IsNullable =true)]
|
||||
//public string UpSecUserIds { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Up主发布的视频存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 500, IsNullable = true)]
|
||||
public string UpSavePath { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 图片视频存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 500, IsNullable = true)]
|
||||
public string ImgSavePath { get; set; }
|
||||
|
||||
|
||||
//[SugarColumn(IsIgnore = true)]
|
||||
//public List<DouyinUpSecUserIdDto> UpSecUserIdsJson
|
||||
//{
|
||||
// get
|
||||
// {
|
||||
// // 反序列化逻辑(保持不变)
|
||||
// if (string.IsNullOrWhiteSpace(UpSecUserIds))
|
||||
// {
|
||||
// return new List<DouyinUpSecUserIdDto>();
|
||||
// }
|
||||
|
||||
// try
|
||||
// {
|
||||
// return JsonConvert.DeserializeObject<List<DouyinUpSecUserIdDto>>(UpSecUserIds);
|
||||
// }
|
||||
// catch (JsonSerializationException ex)
|
||||
// {
|
||||
// // 日志记录(按需添加)
|
||||
// // Logger.Error($"反序列化失败:{ex.Message},原始值:{UpSecUserIds}");
|
||||
// return new List<DouyinUpSecUserIdDto>();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// // 日志记录(按需添加)
|
||||
// // Logger.Error($"处理异常:{ex.Message}");
|
||||
// return new List<DouyinUpSecUserIdDto>();
|
||||
// }
|
||||
// }
|
||||
// set
|
||||
// {
|
||||
// // 序列化逻辑:将列表转为JSON字符串,赋值给UpSecUserIds
|
||||
// try
|
||||
// {
|
||||
// // 若传入的列表为null,直接设为空字符串(避免序列化后出现"null"字符串)
|
||||
// UpSecUserIds = value == null
|
||||
// ? string.Empty
|
||||
// : JsonConvert.SerializeObject(value);
|
||||
// }
|
||||
// catch (JsonSerializationException ex)
|
||||
// {
|
||||
// // 捕获序列化异常(如对象循环引用、不支持的类型等)
|
||||
// // Logger.Error($"序列化失败:{ex.Message},列表值:{value}");
|
||||
// // 异常时默认设为空字符串,避免存储错误的JSON
|
||||
// UpSecUserIds = string.Empty;
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// // 捕获其他未知异常
|
||||
// // Logger.Error($"设置值异常:{ex.Message}");
|
||||
// UpSecUserIds = string.Empty;
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Newtonsoft.Json;
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.model
|
||||
{
|
||||
[SugarTable(TableName = "dy_follow")]
|
||||
public class DouyinFollowed
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[SugarColumn(IsPrimaryKey = true)]
|
||||
public string Id { get; set; }
|
||||
//[SugarColumn(IsNullable = false,Length =200)]
|
||||
//public string UperId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 博主sec_uid
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable = false,Length =500)]
|
||||
public string SecUid { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = false, Length =200)]
|
||||
public string UperName { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true, Length =1000)]
|
||||
public string UperAvatar { get; set; }
|
||||
/// <summary>
|
||||
/// 官方账号(企业认证)
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable = true, Length =200)]
|
||||
public string Enterprise { get; set; }
|
||||
/// <summary>
|
||||
/// 是否开启同步
|
||||
/// </summary>
|
||||
public bool OpenSync { get; set; }
|
||||
/// <summary>
|
||||
/// 是否全量同步
|
||||
/// </summary>
|
||||
public bool FullSync { get; set; }
|
||||
/// <summary>
|
||||
/// 最后更新时间
|
||||
/// </summary>
|
||||
public DateTime LastSyncTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 我的userId,关注者的抖音userid
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable = false,Length =200)]
|
||||
public string mySelfId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签名
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable = true,Length =500)]
|
||||
public string Signature { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同步文件保存路径
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable = true,Length =500)]
|
||||
public string SavePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 博主Id
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable = true,Length =100)]
|
||||
public string UperId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
using dy.net.dto;
|
||||
using Newtonsoft.Json;
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.model
|
||||
{
|
||||
[SugarTable(TableName = "dy_cookie")]
|
||||
public class DouyinUserCookie
|
||||
{
|
||||
|
||||
[SugarColumn(IsPrimaryKey = true)]
|
||||
public string Id { get; set; }
|
||||
/// <summary>
|
||||
/// 用户账号-唯一就行,手机号啥的
|
||||
/// </summary>
|
||||
|
||||
//public string UserId { get; set; }
|
||||
/// <summary>
|
||||
/// 用户描述
|
||||
/// </summary>
|
||||
public string UserName { get; set; }
|
||||
/// <summary>
|
||||
/// 用户抖音Cookie
|
||||
/// </summary>
|
||||
[SugarColumn(Length =-1,IsNullable =true)]
|
||||
public string Cookies { get; set; }
|
||||
/// <summary>
|
||||
/// 存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length =255,IsNullable =true)]
|
||||
public string SavePath { get; set; }
|
||||
|
||||
public int Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 同步喜欢的视频需要sec_user_id
|
||||
/// </summary>
|
||||
[SugarColumn(Length =500,IsNullable =true)]
|
||||
public string SecUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 喜欢的视频存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length =500,IsNullable =true)]
|
||||
public string FavSavePath { get; set; }
|
||||
|
||||
///// <summary>
|
||||
///// 最新收藏夹的分页页码
|
||||
///// </summary>
|
||||
//[SugarColumn(Length =500,IsNullable =true)]
|
||||
//public string CollectMaxCursor { get; set; }
|
||||
///// <summary>
|
||||
///// 最新我喜欢的分页页码
|
||||
///// </summary>
|
||||
//[SugarColumn(Length = 500, IsNullable = true)]
|
||||
//public string FavoriteMaxCursor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已经同步过了(就是是否是第一次同步) 0-未同步 1-已同步
|
||||
/// 如果是0,则同步时会获取全部数据;如果是1,则同步时只获取最新的数据(也就是只查一次)
|
||||
/// 接口可以将该值改为0,下次开始同步就会重新获取全部数据
|
||||
/// </summary>
|
||||
public int CollHasSyncd { get; set; }
|
||||
public int FavHasSyncd { get; set; }
|
||||
public int UperSyncd { get; set; }
|
||||
/// <summary>
|
||||
/// 关注的用户sec_user_id列表 json字符串存储
|
||||
/// </summary>
|
||||
[SugarColumn(Length =-1,IsNullable =true)]
|
||||
public string UpSecUserIds { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Up主发布的视频存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 500, IsNullable = true)]
|
||||
public string UpSavePath { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 图片视频存储路径
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 500, IsNullable = true)]
|
||||
public string ImgSavePath { get; set; }
|
||||
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public List<DouyinUpSecUserIdDto> UpSecUserIdsJson
|
||||
{
|
||||
get
|
||||
{
|
||||
// 反序列化逻辑(保持不变)
|
||||
if (string.IsNullOrWhiteSpace(UpSecUserIds))
|
||||
{
|
||||
return new List<DouyinUpSecUserIdDto>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<List<DouyinUpSecUserIdDto>>(UpSecUserIds);
|
||||
}
|
||||
catch (JsonSerializationException ex)
|
||||
{
|
||||
// 日志记录(按需添加)
|
||||
// Logger.Error($"反序列化失败:{ex.Message},原始值:{UpSecUserIds}");
|
||||
return new List<DouyinUpSecUserIdDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 日志记录(按需添加)
|
||||
// Logger.Error($"处理异常:{ex.Message}");
|
||||
return new List<DouyinUpSecUserIdDto>();
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
// 序列化逻辑:将列表转为JSON字符串,赋值给UpSecUserIds
|
||||
try
|
||||
{
|
||||
// 若传入的列表为null,直接设为空字符串(避免序列化后出现"null"字符串)
|
||||
UpSecUserIds = value == null
|
||||
? string.Empty
|
||||
: JsonConvert.SerializeObject(value);
|
||||
}
|
||||
catch (JsonSerializationException ex)
|
||||
{
|
||||
// 捕获序列化异常(如对象循环引用、不支持的类型等)
|
||||
// Logger.Error($"序列化失败:{ex.Message},列表值:{value}");
|
||||
// 异常时默认设为空字符串,避免存储错误的JSON
|
||||
UpSecUserIds = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获其他未知异常
|
||||
// Logger.Error($"设置值异常:{ex.Message}");
|
||||
UpSecUserIds = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.model
|
||||
{
|
||||
/// <summary>
|
||||
/// 重新下载的列表
|
||||
/// </summary>
|
||||
[SugarTable(TableName = "dy_rd_video")]
|
||||
public class ViedoReDown
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[SugarColumn(IsPrimaryKey = true)]
|
||||
public string Id { get; set; }
|
||||
/// <summary>
|
||||
/// 原视频Id
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable =true,Length =50)]
|
||||
public string ViedoId { get; set; }
|
||||
/// <summary>
|
||||
/// 原保存目录
|
||||
/// </summary>
|
||||
[SugarColumn(IsNullable =true,Length =1000)]
|
||||
public string SavePath { get; set; }
|
||||
|
||||
public string CookieId { get; set; }
|
||||
/// <summary>
|
||||
/// 0-未下载 1-下载成功 2-下载失败
|
||||
/// </summary>
|
||||
public int Status { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
public DateTime DownTime { get; set; }
|
||||
public DateTime UpdateTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,21 @@ namespace dy.net.repository
|
||||
{
|
||||
return Db.Deleteable<T>().In(id).ExecuteCommand() > 0;
|
||||
}
|
||||
/// <summary>
|
||||
/// 事务执行
|
||||
/// </summary>
|
||||
/// <param name="action"></param>
|
||||
/// <param name="errorCallBack"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> UseTranAsync(Func<Task> action, Action<Exception> errorCallBack)
|
||||
{
|
||||
var res = Db.Ado.UseTranAsync(async () =>
|
||||
{
|
||||
await action();
|
||||
}, errorCallBack: errorCallBack);
|
||||
|
||||
return res.IsCompletedSuccessfully;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据主键删除(异步)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using dy.net.model;
|
||||
using SqlSugar;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace dy.net.repository
|
||||
{
|
||||
public class DouyinCookieRepository : BaseRepository<DouyinCookie>
|
||||
{
|
||||
// 注入SQLSugar客户端
|
||||
public DouyinCookieRepository(ISqlSugarClient db) : base(db)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<List<DouyinCookie>> GetAllCookies(Expression<Func<DouyinCookie, bool>> whereExpression = null)
|
||||
{
|
||||
// 1. 初始化查询:先加固定条件 Status == 1
|
||||
var query = Db.Queryable<DouyinCookie>()
|
||||
.Where(x => x.Status == 1); // 固定条件(必选)
|
||||
|
||||
// 2. 若传入自定义条件,叠加 Where(自动 AND 组合)
|
||||
if (whereExpression != null)
|
||||
{
|
||||
query = query.Where(whereExpression); // 自定义条件(可选)
|
||||
}
|
||||
|
||||
// 3. 执行查询(SqlSugar 自动合并所有 Where 条件)
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<(List<DouyinCookie> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize)
|
||||
{
|
||||
var where = this.Db.Queryable<DouyinCookie>();
|
||||
|
||||
var totalCount = await where.CountAsync();
|
||||
var list = await where.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
return (list, totalCount);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using ClockSnowFlake;
|
||||
using dy.net.dto;
|
||||
using dy.net.extension;
|
||||
using dy.net.model;
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.repository
|
||||
{
|
||||
public class DouyinFollowRepository : BaseRepository<DouyinFollowed>
|
||||
{
|
||||
// 注入SQLSugar客户端
|
||||
public DouyinFollowRepository(ISqlSugarClient db) : base(db)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询收藏视频
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns>分页结果(视频列表和总数)</returns>
|
||||
public async Task<(List<DouyinFollowed> list, int totalCount)> GetPagedAsync(FollowRequestDto dto)
|
||||
{
|
||||
var where = this.Db.Queryable<DouyinFollowed>()
|
||||
.Where(x=>x.mySelfId==dto.MySelfId)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(dto.FollowUserName), x => x.UperName.Contains(dto.FollowUserName));
|
||||
var totalCount = await where.CountAsync();
|
||||
var list = await where.OrderByDescending(x=>x.OpenSync).OrderByDescending(x => x.LastSyncTime).Skip((dto.PageIndex - 1) * dto.PageSize).Take(dto.PageSize).ToListAsync();
|
||||
return (list, totalCount);
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> BatchInsert(List<DouyinFollowed> followeds)
|
||||
{
|
||||
return await Db.Insertable(followeds).ExecuteCommandAsync() > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<bool> BatchUpdate(List<DouyinFollowed> followeds)
|
||||
{
|
||||
return await Db.Updateable(followeds).ExecuteCommandAsync() > 0;
|
||||
}
|
||||
|
||||
|
||||
public async Task<DouyinFollowed> GetBySecUId(string secUid)
|
||||
{
|
||||
return await this.GetFirstAsync(x => x.SecUid == secUid);
|
||||
}
|
||||
|
||||
|
||||
public async Task<DouyinFollowed> GetBySecUId(string uperId,string myId)
|
||||
{
|
||||
return await this.GetFirstAsync(x => x.UperId == uperId && x.mySelfId == myId);
|
||||
}
|
||||
public async Task<bool> Update(DouyinFollowed followed)
|
||||
{
|
||||
return await this.UpdateAsync(followed);
|
||||
}
|
||||
|
||||
public async Task<bool> Insert(DouyinFollowed followed)
|
||||
{
|
||||
return await this.InsertAsync(followed);
|
||||
}
|
||||
|
||||
public async Task<List<DouyinFollowed>> GetSyncFollows(string userId)
|
||||
{
|
||||
return await this.Db.Queryable<DouyinFollowed>()
|
||||
.Where(x => x.OpenSync == true).Where(x=>x.mySelfId== userId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同步关注列表(新增名字和签名变更检测)
|
||||
/// </summary>
|
||||
/// <param name="followInfos"></param>
|
||||
/// <param name="myselfUserId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> Sync(List<FollowingsItem> followInfos, string myselfUserId)
|
||||
{
|
||||
// 基础参数校验
|
||||
if (followInfos == null) followInfos = new List<FollowingsItem>();
|
||||
if (string.IsNullOrWhiteSpace(myselfUserId))
|
||||
{
|
||||
Serilog.Log.Error("同步关注列表失败:当前用户ID为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 查询现有关注列表
|
||||
List<DouyinFollowed> existFollows = await Db.Queryable<DouyinFollowed>()
|
||||
.Where(x => x.mySelfId == myselfUserId)
|
||||
.ToListAsync() ?? new List<DouyinFollowed>();
|
||||
|
||||
// 2. 提取现有和当前的SecUid集合(去重优化)
|
||||
HashSet<string> existSecUids = existFollows.Select(x => x.SecUid).ToHashSet();
|
||||
HashSet<string> currentSecUids = followInfos.Select(x => x.SecUid).ToHashSet();
|
||||
|
||||
// 3. 计算新增、待删除和需要更新的记录
|
||||
var toAddFollows = followInfos.Where(x => !existSecUids.Contains(x.SecUid)).ToList();
|
||||
var toRemoveFollows = existFollows.Where(x => !currentSecUids.Contains(x.SecUid)).ToList();
|
||||
|
||||
// 3.1 筛选需要更新的记录(SecUid存在但名字或签名有变更)
|
||||
var toUpdateFollows = new List<DouyinFollowed>();
|
||||
foreach (var existFollow in existFollows)
|
||||
{
|
||||
var newFollow = followInfos.FirstOrDefault(x => x.SecUid == existFollow.SecUid);
|
||||
if (newFollow == null) continue;
|
||||
|
||||
// 检查名字或签名是否变更(精确匹配,区分大小写和空格)
|
||||
bool nameChanged = !string.Equals(existFollow.UperName, newFollow.NickName, StringComparison.Ordinal);
|
||||
bool signatureChanged = !string.Equals(existFollow.Signature, newFollow.Signature, StringComparison.Ordinal);
|
||||
bool enterpriseChanged = !string.Equals(existFollow.Enterprise, newFollow.EnterpriseVerifyReason, StringComparison.Ordinal);
|
||||
bool uperAvatarChanged = !string.Equals(existFollow.UperAvatar, newFollow.Avatar.UrlList?.FirstOrDefault()??"", StringComparison.Ordinal);
|
||||
//bool uperIdChanged = !string.Equals(existFollow.UperId, newFollow.UperId, StringComparison.Ordinal);
|
||||
|
||||
if (nameChanged || signatureChanged|| uperAvatarChanged|| enterpriseChanged)
|
||||
{
|
||||
// 构造更新实体(仅赋值变更字段和必要字段)
|
||||
var updateEntity = new DouyinFollowed
|
||||
{
|
||||
Id = existFollow.Id, // 主键必须保留,用于匹配
|
||||
mySelfId = existFollow.mySelfId,
|
||||
SecUid = existFollow.SecUid,
|
||||
UperName = newFollow.NickName, // 新名字
|
||||
Signature = newFollow.Signature, // 新签名
|
||||
UperId = newFollow.UperId, // 新的UperId
|
||||
UperAvatar = newFollow.Avatar?.UrlList?.FirstOrDefault() ?? "", // 新头像
|
||||
Enterprise = newFollow.EnterpriseVerifyReason, // 新企业认证
|
||||
LastSyncTime = DateTime.UtcNow // 更新同步时间
|
||||
// 其他字段(如Enterprise、UperAvatar、OpenSync)保留原有值,无需赋值
|
||||
};
|
||||
toUpdateFollows.Add(updateEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 分批处理新增(单批200条)
|
||||
if (toAddFollows.Any())
|
||||
{
|
||||
Func<FollowingsItem, DouyinFollowed> mapToDouyinFollowed = follow => new DouyinFollowed
|
||||
{
|
||||
Id = IdGener.GetLong().ToString(),
|
||||
Enterprise = follow.EnterpriseVerifyReason,
|
||||
LastSyncTime = DateTime.UtcNow,
|
||||
mySelfId = myselfUserId,
|
||||
SecUid = follow.SecUid,
|
||||
OpenSync = false,
|
||||
UperAvatar = follow.Avatar?.UrlList?.FirstOrDefault() ?? "",
|
||||
UperName = follow.NickName,
|
||||
Signature = follow.Signature,
|
||||
UperId = follow.UperId
|
||||
};
|
||||
|
||||
bool batchAddSuccess = await BatchProcessAsync(toAddFollows, 200,
|
||||
async batch => await BatchInsert(batch.Select(mapToDouyinFollowed).ToList()));
|
||||
|
||||
if (!batchAddSuccess)
|
||||
{
|
||||
Serilog.Log.Error("同步关注列表失败:新增关注分批插入异常");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 分批处理更新(适配 SQLSugar 语法:UpdateColumns + WhereColumns)
|
||||
if (toUpdateFollows.Any())
|
||||
{
|
||||
bool batchUpdateSuccess = await BatchProcessAsync(toUpdateFollows, 200,
|
||||
async batch =>
|
||||
{
|
||||
// SQLSugar 正确用法:实体集合更新 + UpdateColumns(指定更新字段) + WhereColumns(指定匹配字段/主键)
|
||||
int affectedRows = await Db.Updateable(batch) // 传入更新实体集合
|
||||
.UpdateColumns(x => new { x.UperName, x.Signature, x.LastSyncTime,x.Enterprise,x.UperAvatar}) // 仅更新这3个字段
|
||||
.WhereColumns(x => x.Id) // 按主键Id匹配现有记录
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
// 受影响行数 > 0 或 批次无数据(正常),返回true;否则返回false
|
||||
return affectedRows >= 0;
|
||||
});
|
||||
|
||||
if (!batchUpdateSuccess)
|
||||
{
|
||||
Serilog.Log.Error("同步关注列表失败:关注信息更新异常");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Debug($"同步关注列表:成功更新{toUpdateFollows.Count}条关注信息(用户ID:{myselfUserId})");
|
||||
}
|
||||
|
||||
// 6. 分批处理删除(单批200条)
|
||||
if (toRemoveFollows.Any())
|
||||
{
|
||||
bool batchDeleteSuccess = await BatchProcessAsync(toRemoveFollows, 200,
|
||||
async batch =>
|
||||
{
|
||||
var secUids = batch.Select(x => x.SecUid).ToList();
|
||||
await DeleteAsync(x => x.mySelfId == myselfUserId && secUids.Contains(x.SecUid));
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!batchDeleteSuccess)
|
||||
{
|
||||
Serilog.Log.Error("同步关注列表失败:取消关注分批删除异常");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Serilog.Log.Debug($"同步关注列表完成(用户ID:{myselfUserId}):新增{toAddFollows.Count}条,更新{toUpdateFollows.Count}条,删除{toRemoveFollows.Count}条");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, $"同步关注列表失败(用户ID:{myselfUserId}):{ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通用分批处理工具方法
|
||||
/// </summary>
|
||||
/// <typeparam name="T">数据类型</typeparam>
|
||||
/// <param name="dataList">待处理数据</param>
|
||||
/// <param name="batchSize">单批大小</param>
|
||||
/// <param name="processAction">单批处理逻辑(返回是否成功)</param>
|
||||
/// <returns>整体处理结果</returns>
|
||||
private async Task<bool> BatchProcessAsync<T>(List<T> dataList, int batchSize, Func<List<T>, Task<bool>> processAction)
|
||||
{
|
||||
if (dataList == null || !dataList.Any() || batchSize <= 0)
|
||||
return true;
|
||||
|
||||
int totalCount = dataList.Count;
|
||||
int batchCount = (int)Math.Ceiling((double)totalCount / batchSize);
|
||||
|
||||
for (int i = 0; i < batchCount; i++)
|
||||
{
|
||||
var batch = dataList.Skip(i * batchSize).Take(batchSize).ToList();
|
||||
if (!batch.Any()) continue;
|
||||
|
||||
bool success = await processAction(batch);
|
||||
if (!success)
|
||||
{
|
||||
Serilog.Log.Debug($"分批处理失败:第{i + 1}批(数据范围:{i * batchSize}-{Math.Min((i + 1) * batchSize - 1, totalCount - 1)})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using dy.net.model;
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.repository
|
||||
{
|
||||
public class DouyinUserCookieRepository : BaseRepository<DouyinUserCookie>
|
||||
{
|
||||
// 注入SQLSugar客户端
|
||||
public DouyinUserCookieRepository(ISqlSugarClient db) : base(db)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<List<DouyinUserCookie>> GetAllCookies()
|
||||
{
|
||||
return await this.GetListAsync(x=>x.Status==1);
|
||||
}
|
||||
|
||||
|
||||
public async Task<(List<DouyinUserCookie> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize)
|
||||
{
|
||||
var where = this.Db.Queryable<DouyinUserCookie>();
|
||||
|
||||
var totalCount = await where.CountAsync();
|
||||
var list = await where.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
return (list, totalCount);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -16,49 +16,47 @@ namespace dy.net.repository
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询收藏视频
|
||||
/// </summary>
|
||||
/// <param name="pageIndex">页码(从1开始)</param>
|
||||
/// <param name="pageSize">每页数量</param>
|
||||
/// <param name="tag">可选标签过滤</param>
|
||||
/// <param name="author">可选作者过滤</param>
|
||||
/// <returns>分页结果(视频列表和总数)</returns>
|
||||
public async Task<(List<DouyinVideo> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, string author = null,string viedoType=null, List<string> dates = null)
|
||||
{
|
||||
|
||||
DateTime? start = null;
|
||||
DateTime? end =null;
|
||||
if(dates!=null && dates.Count==2)
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(List<DouyinVideo> list, int totalCount)> GetPagedAsync(DouyinVideoPageRequestDto dto)
|
||||
{
|
||||
DateTime? start, end;
|
||||
GetDateBetween(dto.Dates, out start, out end);
|
||||
|
||||
DateTime? start2, end2;
|
||||
GetDateBetween(dto.Dates2, out start2, out end2);
|
||||
|
||||
|
||||
VideoTypeEnum? enumviedoType = null;
|
||||
if (!string.IsNullOrEmpty(dto.ViedoType) && dto.ViedoType != "*")
|
||||
{
|
||||
start = Convert.ToDateTime(dates[0]);
|
||||
end = Convert.ToDateTime(dates[1]);
|
||||
enumviedoType = dto.ViedoType.ToVideoTypeEnum();
|
||||
}
|
||||
else if(dates!=null && dates.Count==1)
|
||||
{
|
||||
start = Convert.ToDateTime(dates[0]);
|
||||
}
|
||||
VideoTypeEnum? enumviedoType = null;
|
||||
if (!string.IsNullOrEmpty(viedoType)&&viedoType!="*")
|
||||
{
|
||||
enumviedoType = viedoType.ToVideoTypeEnum();
|
||||
}
|
||||
var where = this.Db.Queryable<DouyinVideo>()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(tag), x => x.Tag1 == tag)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author)
|
||||
//.WhereIF(!string.IsNullOrWhiteSpace(title), x => x.VideoTitle.Contains(title))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(dto.Title), x => x.VideoTitle.Contains(dto.Title))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(dto.Author), x => x.Author == dto.Author)
|
||||
.WhereIF(start.HasValue, x => x.SyncTime >= start.Value)
|
||||
.WhereIF(end.HasValue, x => x.SyncTime <= end.Value)
|
||||
.WhereIF(start2.HasValue, x => x.CreateTime >= start2.Value)
|
||||
.WhereIF(end2.HasValue, x => x.CreateTime <= end2.Value)
|
||||
.WhereIF(enumviedoType.HasValue, x => x.ViedoType == enumviedoType);
|
||||
|
||||
|
||||
var totalCount = await where.CountAsync();
|
||||
var list = await where.OrderByDescending(x=>x.SyncTime).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
if (list.Any()) {
|
||||
var users= await this.Db.Queryable<DouyinUserCookie>().ToListAsync();
|
||||
var list = await where.OrderByDescending(x => x.SyncTime).Skip((dto.PageIndex - 1) * dto.PageSize).Take(dto.PageSize).ToListAsync();
|
||||
if (list.Any())
|
||||
{
|
||||
var users = await this.Db.Queryable<DouyinCookie>().ToListAsync();
|
||||
foreach (var item in list)
|
||||
{
|
||||
var user= users.FirstOrDefault(x=>x.Id== item.CookieId);
|
||||
if(user!=null)
|
||||
var user = users.FirstOrDefault(x => x.Id == item.CookieId);
|
||||
if (user != null)
|
||||
{
|
||||
item.DyUser = user.UserName;
|
||||
}
|
||||
@@ -67,19 +65,34 @@ namespace dy.net.repository
|
||||
return (list, totalCount);
|
||||
}
|
||||
|
||||
private static void GetDateBetween(List<string> dates, out DateTime? start, out DateTime? end)
|
||||
{
|
||||
start = null;
|
||||
end = null;
|
||||
if (dates != null && dates.Count == 2)
|
||||
{
|
||||
start = Convert.ToDateTime(dates[0]);
|
||||
end = Convert.ToDateTime(dates[1]);
|
||||
}
|
||||
else if (dates != null && dates.Count == 1)
|
||||
{
|
||||
start = Convert.ToDateTime(dates[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// 对于重复标题进行处理
|
||||
/// </summary>
|
||||
/// <param name="AuthorId"></param>
|
||||
/// <param name="ViedoNameSimplify"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId,string ViedoNameSimplify)
|
||||
public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
|
||||
{
|
||||
|
||||
var video= await this.Db.Queryable<DouyinVideo>().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.UperPost)
|
||||
.Where(x => x.VideoTitleSimplify == ViedoNameSimplify)
|
||||
.OrderByDescending(x => x.CreateTime).FirstAsync();
|
||||
var video = await this.Db.Queryable<DouyinVideo>().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.UperPost)
|
||||
.Where(x => x.VideoTitleSimplify == ViedoNameSimplify)
|
||||
.OrderByDescending(x => x.CreateTime).FirstAsync();
|
||||
|
||||
if (video != null)
|
||||
{
|
||||
@@ -107,9 +120,88 @@ namespace dy.net.repository
|
||||
{
|
||||
return (ViedoNameSimplify, "");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据视频ID列表获取视频信息
|
||||
/// </summary>
|
||||
/// <param name="ids"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<List<DouyinVideo>> GetByIds(List<string> ids)
|
||||
{
|
||||
// 使用 Queryable 方法构建查询
|
||||
return await this.Db.Queryable<DouyinVideo>()
|
||||
.Where(x => ids.Contains(x.Id))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录要重新下载的视频
|
||||
/// </summary>
|
||||
/// <param name="downs"></param>
|
||||
/// <returns></returns>
|
||||
public bool InsertReDowns(List<ViedoReDown> downs)
|
||||
{
|
||||
if (downs != null)
|
||||
{
|
||||
return Db.Insertable(downs).ExecuteCommand() > 0;
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新视频重新下载状态
|
||||
/// </summary>
|
||||
/// <param name="videoId">视频ID(对应ViedoReDown.Id)</param>
|
||||
/// <param name="status">状态值(1时会同步更新下载时间)</param>
|
||||
/// <returns>是否更新成功(影响行数>0)</returns>
|
||||
public async Task<bool> UpdateReDownStatus(string videoId, int status)
|
||||
{
|
||||
// 校验必填参数(避免无效数据库操作)
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
{
|
||||
Serilog.Log.Error("更新重新下载状态失败:videoId为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 构建更新条件(统一Where条件,避免重复代码)
|
||||
var updateable = Db.Updateable<ViedoReDown>()
|
||||
.Where(it => it.Id == videoId)
|
||||
.SetColumns(it => new ViedoReDown
|
||||
{
|
||||
Status = status,
|
||||
UpdateTime = DateTime.Now
|
||||
});
|
||||
|
||||
if (status == 1)
|
||||
{
|
||||
updateable = updateable.SetColumns(it => it.DownTime == DateTime.Now);
|
||||
|
||||
}
|
||||
int affectedRows = await updateable.ExecuteCommandAsync();
|
||||
|
||||
// 可选:记录更新结果日志
|
||||
if (affectedRows <= 0)
|
||||
{
|
||||
Serilog.Log.Debug("更新重新下载状态无匹配数据:videoId={0}, status={1}", videoId, status);
|
||||
}
|
||||
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<ViedoReDown>> GetViedoReDowns()
|
||||
{
|
||||
return await this.Db.Queryable<ViedoReDown>()
|
||||
.Where(x => x.Status == 0 || x.Status == 2)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +48,18 @@ namespace dy.net.service
|
||||
AppConfig config = new AppConfig
|
||||
{
|
||||
Id = IdGener.GetLong().ToString(),
|
||||
Cron = "30",
|
||||
BatchCount = 10,
|
||||
Cron = 30,
|
||||
BatchCount = 18,
|
||||
LogKeepDay = 10,
|
||||
UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹
|
||||
UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名
|
||||
DownImageVideo = downLoadImage,
|
||||
DownMp3 = false,
|
||||
DownImage = false
|
||||
DownImage = false,
|
||||
ImageViedoSaveAlone = true,
|
||||
FollowedTitleTemplate ="",
|
||||
FullFollowedTitleTemplate="",
|
||||
FollowedTitleSeparator=""
|
||||
};
|
||||
sqlSugarClient.Insertable(config).ExecuteCommand();
|
||||
return config;
|
||||
@@ -81,11 +85,16 @@ namespace dy.net.service
|
||||
cc.UperSaveTogether = config.UperSaveTogether;
|
||||
cc.UperUseViedoTitle = config.UperUseViedoTitle;
|
||||
cc.LogKeepDay = config.LogKeepDay;
|
||||
cc.DownMp3= config.DownMp3;
|
||||
cc.DownImage= config.DownImage;
|
||||
cc.DownMp3 = config.DownMp3;
|
||||
cc.DownImage = config.DownImage;
|
||||
cc.FollowedTitleTemplate = config.FollowedTitleTemplate;
|
||||
cc.FullFollowedTitleTemplate= config.FullFollowedTitleTemplate;
|
||||
cc.ImageViedoSaveAlone = config.ImageViedoSaveAlone;
|
||||
cc.FollowedTitleSeparator = config.FollowedTitleSeparator;
|
||||
cc.AutoDistinct = config.AutoDistinct;
|
||||
}
|
||||
|
||||
var update = await sqlSugarClient.Updateable<AppConfig>(cc).ExecuteCommandAsync();
|
||||
int update = await sqlSugarClient.Updateable(cc).ExecuteCommandAsync();
|
||||
|
||||
return update > 0;
|
||||
}
|
||||
@@ -96,7 +105,7 @@ namespace dy.net.service
|
||||
public void UpdateCollectViedoType()
|
||||
{
|
||||
|
||||
string sql= @"UPDATE dy_collect_video
|
||||
string sql = @"UPDATE dy_collect_video
|
||||
SET ViedoType = CASE
|
||||
WHEN ViedoType = '0' THEN 0
|
||||
WHEN ViedoType = '1' THEN 1
|
||||
@@ -124,33 +133,30 @@ namespace dy.net.service
|
||||
/// <returns></returns>
|
||||
public bool UpdateAllCookieSyncedToZero()
|
||||
{
|
||||
//var sql = "update dy_cookie set CollHasSyncd=0,FavHasSyncd=0,UperSyncd=0";
|
||||
// sqlSugarClient.Ado.ExecuteCommand(sql) > 0;
|
||||
var cookies = sqlSugarClient.Queryable<DouyinUserCookie>().ToList();
|
||||
|
||||
var cookies = sqlSugarClient.Queryable<DouyinCookie>().ToList();
|
||||
foreach (var cookie in cookies)
|
||||
{
|
||||
cookie.CollHasSyncd = 0;
|
||||
cookie.FavHasSyncd = 0;
|
||||
cookie.UperSyncd = 0;
|
||||
var upers = cookie.UpSecUserIds;
|
||||
if (!string.IsNullOrWhiteSpace(upers))
|
||||
{
|
||||
var uperList = Newtonsoft.Json.JsonConvert.DeserializeObject<List<DouyinUpSecUserIdDto>>(upers);
|
||||
if (uperList != null && uperList.Count > 0)
|
||||
{
|
||||
foreach (var uper in uperList)
|
||||
{
|
||||
uper.syncAll = false;
|
||||
}
|
||||
}
|
||||
var newUpers = Newtonsoft.Json.JsonConvert.SerializeObject(uperList);
|
||||
cookie.UpSecUserIds = newUpers;
|
||||
}
|
||||
}
|
||||
return sqlSugarClient.Updateable(cookies).ExecuteCommand() > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询需要下载的所有重下载视频记录
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<ViedoReDown>> GetAllRedown()
|
||||
{
|
||||
return await sqlSugarClient.Queryable<ViedoReDown>().Where(x => x.Status == 0 || x.Status == 2).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateRedownStatus(List<ViedoReDown> list)
|
||||
{
|
||||
return await sqlSugarClient.Updateable(list).ExecuteCommandAsync() > 0;
|
||||
}
|
||||
#region 测试创建数据库
|
||||
|
||||
///// <summary>
|
||||
|
||||
@@ -8,20 +8,20 @@ namespace dy.net.service
|
||||
public class DouyinCookieService
|
||||
{
|
||||
|
||||
private readonly DouyinUserCookieRepository _cookieRepository;
|
||||
private readonly DouyinCookieRepository _cookieRepository;
|
||||
|
||||
public DouyinCookieService(DouyinUserCookieRepository cookieRepository)
|
||||
public DouyinCookieService(DouyinCookieRepository cookieRepository)
|
||||
{
|
||||
_cookieRepository = cookieRepository;
|
||||
}
|
||||
|
||||
public Task<List<DouyinUserCookie>> GetAllCookies()
|
||||
public Task<List<DouyinCookie>> GetAllOpendAsync(Expression<Func<DouyinCookie, bool>> whereExpression = null)
|
||||
{
|
||||
return _cookieRepository.GetAllCookies();
|
||||
return _cookieRepository.GetAllCookies(whereExpression);
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> Add(DouyinUserCookie dyUserCookies)
|
||||
public async Task<bool> Add(DouyinCookie dyUserCookies)
|
||||
{
|
||||
return await _cookieRepository.InsertAsync(dyUserCookies);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ namespace dy.net.service
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var cookie = new DouyinUserCookie
|
||||
var cookie = new DouyinCookie
|
||||
{
|
||||
UserName = "douyin",
|
||||
Cookies = "--",
|
||||
@@ -53,19 +53,19 @@ namespace dy.net.service
|
||||
}
|
||||
|
||||
// 查询单个
|
||||
public async Task<DouyinUserCookie> GetByIdAsync(string id)
|
||||
public async Task<DouyinCookie> GetByIdAsync(string id)
|
||||
{
|
||||
return await _cookieRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
// 查询列表(可加条件)
|
||||
public async Task<(List<DouyinUserCookie> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize)
|
||||
public async Task<(List<DouyinCookie> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize)
|
||||
{
|
||||
return await _cookieRepository.GetPagedAsync(pageIndex, pageSize);
|
||||
}
|
||||
|
||||
// 更新
|
||||
public async Task<bool> UpdateAsync(DouyinUserCookie dyUserCookies)
|
||||
public async Task<bool> UpdateAsync(DouyinCookie dyUserCookies)
|
||||
{
|
||||
return await _cookieRepository.UpdateAsync(dyUserCookies);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.repository;
|
||||
using SqlSugar;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace dy.net.service
|
||||
{
|
||||
public class DouyinFollowService
|
||||
{
|
||||
|
||||
private readonly DouyinFollowRepository _followRepository;
|
||||
|
||||
public DouyinFollowService(DouyinFollowRepository followRepository)
|
||||
{
|
||||
_followRepository = followRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(List<DouyinFollowed> list, int totalCount)> GetPagedAsync(FollowRequestDto dto)
|
||||
{
|
||||
return await _followRepository.GetPagedAsync(dto);
|
||||
}
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="followInfos"></param>
|
||||
/// <param name="myselfUserId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> Sync(List<FollowingsItem> followInfos, string myselfUserId)
|
||||
{
|
||||
return await _followRepository.Sync(followInfos, myselfUserId);
|
||||
}
|
||||
|
||||
public async Task<DouyinFollowed> GetByUperId(string uperId,string myUid)
|
||||
{
|
||||
return await _followRepository.GetBySecUId(uperId, myUid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开或关闭同步
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> OpenOrCloseSync(FollowUpdateDto dto)
|
||||
{
|
||||
var followed = await _followRepository.GetByIdAsync(dto.Id);
|
||||
if (followed != null)
|
||||
{
|
||||
followed.OpenSync = dto.OpenSync;
|
||||
followed.FullSync = dto.FullSync;
|
||||
followed.SavePath = dto.SavePath;
|
||||
return await _followRepository.Update(followed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 打开或关闭同步
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> OpenOrCloseFullSync(FollowUpdateDto dto)
|
||||
{
|
||||
var followed = await _followRepository.GetByIdAsync(dto.Id);
|
||||
if (followed != null)
|
||||
{
|
||||
followed.FullSync = dto.FullSync;
|
||||
return await _followRepository.Update(followed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取需要同步的关注列表
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<DouyinFollowed>> GetSyncFollows(string userUserId)
|
||||
{
|
||||
return await _followRepository.GetSyncFollows(userUserId);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.utils;
|
||||
using NetTaste;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection.PortableExecutable;
|
||||
|
||||
namespace dy.net.service
|
||||
{
|
||||
@@ -195,10 +198,10 @@ namespace dy.net.service
|
||||
// 构建请求URL
|
||||
var queryString = new FormUrlEncodedContent(parameters);
|
||||
string fullUrl = $"{DouYinApi}/post?{await queryString.ReadAsStringAsync()}";
|
||||
var ablog = new ABogus();//计算X-Bogus
|
||||
var a_bogus = ablog.GetValue(parameters);
|
||||
//var ablog = new ABogus();//计算X-Bogus
|
||||
//var a_bogus = ablog.GetValue(parameters);
|
||||
|
||||
fullUrl += $"&X-Bogus={a_bogus}";
|
||||
//fullUrl += $"&X-Bogus={a_bogus}";
|
||||
var respose = await httpClient.GetAsync(fullUrl);
|
||||
if (respose.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -219,6 +222,61 @@ namespace dy.net.service
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 查询我的关注用户列表
|
||||
/// </summary>
|
||||
/// <param name="count"></param>
|
||||
/// <param name="offset"></param>
|
||||
/// <param name="secUserId"></param>
|
||||
/// <param name="cookie"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<DouyinFollowInfo> SyncMyFollows(string count,string offset,string secUserId,string cookie)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
using var httpClient = _clientFactory.CreateClient("dy_follow");
|
||||
if (httpClient.DefaultRequestHeaders.Contains("Cookie"))
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Remove("Cookie");
|
||||
}
|
||||
httpClient.DefaultRequestHeaders.Add("Cookie", cookie);
|
||||
var dics = DouyinBaseParamDics.MyFollowParams;
|
||||
{
|
||||
// 添加动态参数
|
||||
dics["sec_user_id"] = secUserId;
|
||||
dics["count"] = count;
|
||||
dics["offset"] = offset;
|
||||
}
|
||||
// 构建请求URL
|
||||
var queryString = new FormUrlEncodedContent(dics);
|
||||
string fullUrl = $"https://www.douyin.com/aweme/v1/web/user/following/list/?{await queryString.ReadAsStringAsync()}";
|
||||
var respose = await httpClient.GetAsync(fullUrl);
|
||||
if (respose.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await respose.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<DouyinFollowInfo>(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
Serilog.Log.Error($"SyncMyFollows fail: {respose.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error($"SyncMyFollows error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> SyncReDown(string awemeId,string cookie)
|
||||
{
|
||||
return false;
|
||||
//有空再研究把.a_bogus算法有问题
|
||||
}
|
||||
|
||||
//AI优化2
|
||||
/// <summary>
|
||||
/// 下载文件并保存到本地(支持重试机制,优化批量下载稳定性)
|
||||
@@ -370,6 +428,28 @@ namespace dy.net.service
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
///// <summary>下载网络文件到指定路径</summary>
|
||||
//public async Task DownloadFileAsync(string url, string savePath)
|
||||
//{
|
||||
// if (string.IsNullOrEmpty(url)) throw new ArgumentNullException(nameof(url));
|
||||
// if (string.IsNullOrEmpty(savePath)) throw new ArgumentNullException(nameof(savePath));
|
||||
|
||||
// // 创建目录(如果不存在)
|
||||
// var directory = Path.GetDirectoryName(savePath)!;
|
||||
// if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
// // 下载文件
|
||||
// using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
// response.EnsureSuccessStatusCode(); // 非2xx状态码抛出异常
|
||||
|
||||
// using var stream = await response.Content.ReadAsStreamAsync();
|
||||
// using var fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
|
||||
// await stream.CopyToAsync(fileStream);
|
||||
//}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 判断异常是否可重试
|
||||
/// </summary>
|
||||
@@ -385,7 +465,7 @@ namespace dy.net.service
|
||||
/// <summary>
|
||||
/// 清理不完整的文件
|
||||
/// </summary>
|
||||
private void CleanupIncompleteFile(string savePath)
|
||||
private static void CleanupIncompleteFile(string savePath)
|
||||
{
|
||||
if (File.Exists(savePath))
|
||||
{
|
||||
@@ -400,212 +480,5 @@ namespace dy.net.service
|
||||
}
|
||||
}
|
||||
|
||||
//--AI优化后的-1
|
||||
|
||||
///// <summary>
|
||||
///// 下载文件并保存到本地(优化批量下载假死问题)
|
||||
///// </summary>
|
||||
///// <param name="videoUrl">文件地址</param>
|
||||
///// <param name="savePath">保存路径</param>
|
||||
///// <param name="cookie">请求Cookie</param>
|
||||
///// <param name="cancellationToken">取消令牌(用于终止卡住的任务)</param>
|
||||
///// <param name="streamTimeout">流读取超时时间(默认30秒)</param>
|
||||
///// <returns>是否下载成功</returns>
|
||||
//public async Task<bool> DownloadAsync(
|
||||
// string videoUrl,
|
||||
// string savePath,
|
||||
// string cookie, string httpclientName=null,
|
||||
// CancellationToken cancellationToken = default,
|
||||
// TimeSpan? streamTimeout = null)
|
||||
//{
|
||||
// // 流读取超时默认60秒(避免长时间无数据导致假死)
|
||||
// var streamTimeoutValue = streamTimeout ?? TimeSpan.FromSeconds(60);
|
||||
// // 记录上次流活动时间(用于超时判断)
|
||||
// DateTime lastStreamActivity = DateTime.UtcNow;
|
||||
|
||||
// try
|
||||
// {
|
||||
// // 确保目录存在
|
||||
// var directory = Path.GetDirectoryName(savePath);
|
||||
// if (!Directory.Exists(directory))
|
||||
// {
|
||||
// Directory.CreateDirectory(directory);
|
||||
// }
|
||||
|
||||
// // 先删除已存在文件(处理文件占用问题)
|
||||
// if (File.Exists(savePath))
|
||||
// {
|
||||
// // 重试删除(防止文件刚被释放)
|
||||
// for (int i = 0; i < 3; i++)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// File.Delete(savePath);
|
||||
// break;
|
||||
// }
|
||||
// catch (IOException) when (i < 2)
|
||||
// {
|
||||
// await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// httpclientName??="dy_down1";
|
||||
// // 使用客户端工厂创建HttpClient(复用连接池)
|
||||
// using (var httpClient = _clientFactory.CreateClient(httpclientName))
|
||||
// {
|
||||
// // 清理并添加Cookie
|
||||
// httpClient.DefaultRequestHeaders.Remove("Cookie");
|
||||
// httpClient.DefaultRequestHeaders.Add("Cookie", cookie);
|
||||
// // 总请求超时(包括连接和初始响应)
|
||||
// httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// // 发起请求(响应头就绪后返回,不等待完整内容)
|
||||
// using (var response = await httpClient.GetAsync(
|
||||
// videoUrl,
|
||||
// HttpCompletionOption.ResponseHeadersRead,
|
||||
// cancellationToken).ConfigureAwait(false))
|
||||
// {
|
||||
// response.EnsureSuccessStatusCode(); // 验证HTTP状态码
|
||||
|
||||
// // 获取文件总大小(用于进度计算)
|
||||
// long? totalBytes = response.Content.Headers.ContentLength;
|
||||
|
||||
// // 读取响应流并写入文件
|
||||
// using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken)
|
||||
// .ConfigureAwait(false))
|
||||
// // 优化FileStream:异步模式+顺序扫描(提升大文件写入效率)
|
||||
// using (var fileStream = new FileStream(
|
||||
// savePath,
|
||||
// FileMode.CreateNew,
|
||||
// FileAccess.Write,
|
||||
// FileShare.None,
|
||||
// bufferSize: 8192,
|
||||
// options: FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
// {
|
||||
// byte[] buffer = new byte[8192];
|
||||
// int bytesRead;
|
||||
// long totalRead = 0;
|
||||
|
||||
// // 循环读取流(带超时和取消检查)
|
||||
// while ((bytesRead = await responseStream.ReadAsync(
|
||||
// buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
// {
|
||||
// // 检查流读取超时(长时间无数据)
|
||||
// if (DateTime.UtcNow - lastStreamActivity > streamTimeoutValue)
|
||||
// {
|
||||
// throw new TimeoutException($"流读取超时({streamTimeoutValue.TotalSeconds}秒无数据)");
|
||||
// }
|
||||
|
||||
// // 写入文件
|
||||
// await fileStream.WriteAsync(
|
||||
// buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// // 更新进度和活动时间
|
||||
// totalRead += bytesRead;
|
||||
// lastStreamActivity = DateTime.UtcNow;
|
||||
|
||||
// // (可选)进度上报逻辑
|
||||
// if (totalBytes.HasValue)
|
||||
// {
|
||||
// double progress = (double)totalRead / totalBytes.Value * 100;
|
||||
// // 可通过事件或委托上报进度:OnProgressChanged(progress);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 确保数据刷入磁盘
|
||||
// await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return true;
|
||||
// }
|
||||
// catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
// {
|
||||
// // 正常取消操作(非错误)
|
||||
// Serilog.Log.Information($"下载被取消:{videoUrl}");
|
||||
// return false;
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// // 记录错误并清理可能的不完整文件
|
||||
// Serilog.Log.Error(ex, $"下载失败:{videoUrl}");
|
||||
// if (File.Exists(savePath))
|
||||
// {
|
||||
// try { File.Delete(savePath); } catch { /* 忽略删除失败 */ }
|
||||
// }
|
||||
// return false;
|
||||
// }
|
||||
//}
|
||||
|
||||
///// <summary>
|
||||
///// 下载文件并保存到本地
|
||||
///// </summary>
|
||||
///// <param name="videoUrl">文件地址</param>
|
||||
///// <param name="savePath">保存路径</param>
|
||||
///// <param name="cookie"></param>
|
||||
//public async Task<bool> DownloadAsync(string videoUrl, string savePath, string cookie)
|
||||
//{
|
||||
// try
|
||||
// {
|
||||
// // 防止文件被占用,先删除已存在的文件
|
||||
// if (File.Exists(savePath))
|
||||
// {
|
||||
// File.Delete(savePath);
|
||||
// }
|
||||
// // 创建HTTP客户端(设置超时时间和请求头)
|
||||
// using (var httpClient = _clientFactory.CreateClient("dy_down"))
|
||||
// {
|
||||
// if (httpClient.DefaultRequestHeaders.Contains("Cookie"))
|
||||
// {
|
||||
// httpClient.DefaultRequestHeaders.Remove("Cookie");
|
||||
// }
|
||||
// httpClient.DefaultRequestHeaders.Add("Cookie", cookie);
|
||||
// httpClient.Timeout = TimeSpan.FromMinutes(5); // 设置5分钟超时
|
||||
|
||||
// // 获取视频流
|
||||
// using (var response = await httpClient.GetAsync(videoUrl, HttpCompletionOption.ResponseHeadersRead))
|
||||
// {
|
||||
// response.EnsureSuccessStatusCode(); // 确保请求成功
|
||||
|
||||
// // 获取文件总大小(用于进度显示)
|
||||
// long? totalBytes = response.Content.Headers.ContentLength;
|
||||
|
||||
// // 读取流并写入文件
|
||||
// using (var stream = await response.Content.ReadAsStreamAsync())
|
||||
// using (var fileStream = new FileStream(savePath, FileMode.CreateNew))
|
||||
// {
|
||||
// byte[] buffer = new byte[8192];
|
||||
// int bytesRead;
|
||||
// long totalRead = 0;
|
||||
|
||||
// // 循环读取并写入
|
||||
// while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
// {
|
||||
// await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
// totalRead += bytesRead;
|
||||
|
||||
// // 显示下载进度
|
||||
// if (totalBytes.HasValue)
|
||||
// {
|
||||
// double progress = (double)totalRead / totalBytes.Value * 100;
|
||||
// //Console.Write($"\r下载进度:{progress:F2}% ({totalRead}/{totalBytes.Value} bytes)");
|
||||
// }
|
||||
// }
|
||||
// //Console.WriteLine(); // 进度显示结束后换行
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Serilog.Log.Error($"DownloadVideoAsync fail: {ex.Message}");
|
||||
// Serilog.Log.Error($"DownloadVideoAsync fail: {ex.StackTrace}");
|
||||
// return false;
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
using dy.net.dto;
|
||||
using dy.net.utils;
|
||||
using Serilog;
|
||||
|
||||
namespace dy.net.service
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// 合并多张图片+音频为视频
|
||||
/// </summary>
|
||||
public class DouyinMergeVideoService
|
||||
{
|
||||
private readonly FFmpegHelper _fFmpegHelper;
|
||||
private readonly DouyinHttpClientService douyinHttpClientService;
|
||||
|
||||
public DouyinMergeVideoService(FFmpegHelper fFmpegHelper,DouyinHttpClientService douyinHttpClientService)
|
||||
{
|
||||
_fFmpegHelper = fFmpegHelper;
|
||||
this.douyinHttpClientService = douyinHttpClientService;
|
||||
}
|
||||
|
||||
// 1. 并发控制:限制同时运行的 FFmpeg 进程数(建议设为 1,FFmpeg 单进程更稳定)
|
||||
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(1, 1);
|
||||
// 重试次数配置
|
||||
private const int RetryCount = 3;
|
||||
// 重试间隔(毫秒)
|
||||
private const int RetryDelay = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// 视频合成
|
||||
/// </summary>
|
||||
/// <param name="cookie"></param>
|
||||
/// <param name="rootPath"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="outputVideoPath"></param>
|
||||
/// <param name="fileNamefolder"></param>
|
||||
/// <param name="mergeImg2Viedo"></param>
|
||||
/// <param name="downImage"></param>
|
||||
/// <param name="downMp3"></param>
|
||||
/// <returns></returns>
|
||||
//public async Task<bool> MergeToVideo(string cookie,string rootPath, MediaMergeRequest request,string outputVideoPath,string fileNamefolder,bool mergeImg2Viedo,bool downImage=false,bool downMp3=false)
|
||||
//{
|
||||
|
||||
// try
|
||||
// {
|
||||
// // 创建唯一临时目录(避免并发冲突)
|
||||
// var tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString());
|
||||
// try
|
||||
// {
|
||||
// // 1. 下载图片
|
||||
// var (rawImages, error) = await DownloadMediaAsync(request.ImageUrls, Path.Combine(tempDir, "raw-images"), "image_", "webp",cookie);
|
||||
// if (!string.IsNullOrEmpty(error))
|
||||
// {
|
||||
// Serilog.Log.Error($"{error}");
|
||||
// return false;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// if(downImage)
|
||||
// {
|
||||
// for (int i = 0; i < rawImages.Length; i++)
|
||||
// {
|
||||
// string sourcePath = rawImages[i];
|
||||
// // 重命名为有规律的文件名,如 temp_001.jpg, temp_002.png
|
||||
// string extension = Path.GetExtension(sourcePath);
|
||||
// string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0
|
||||
// string destPath = Path.Combine(fileNamefolder, destFileName);
|
||||
// if (destPath.Contains("小可爱") || sourcePath.Contains("小可爱")) {
|
||||
// Console.WriteLine("发现小可爱图片");
|
||||
// }
|
||||
// if (!File.Exists(destPath))
|
||||
// File.Copy(sourcePath, destPath);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// // 2. 下载音频
|
||||
// var (rawAudios, audioError) = await DownloadMediaAsync(request.AudioUrls, Path.Combine(tempDir, "raw-audios"), "audio_", "mp3", cookie);
|
||||
// if (!string.IsNullOrEmpty(audioError))
|
||||
// {
|
||||
// Serilog.Log.Error($"{audioError}");
|
||||
// return false;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// if(downMp3)
|
||||
// {
|
||||
// for (int i = 0; i < rawAudios.Length; i++)
|
||||
// {
|
||||
// string sourcePath = rawAudios[i];
|
||||
// // 重命名为有规律的文件名,如 temp_001.mp3, temp_002.mp3
|
||||
// string extension = Path.GetExtension(sourcePath);
|
||||
// string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0
|
||||
// string destPath = Path.Combine(fileNamefolder, destFileName);
|
||||
// if (!File.Exists(destPath))
|
||||
// File.Copy(sourcePath, destPath);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (!mergeImg2Viedo)
|
||||
// {
|
||||
// // 不合成视频,直接返回成功
|
||||
// Serilog.Log.Debug($"不合成视频,直接返回");
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// // 4. 合成视频
|
||||
// //var outputVideoPath = Path.Combine(tempDir, "output", $"merged-video.{request.OutputFormat.ToLower()}");
|
||||
|
||||
// // 2. 创建帮助类实例
|
||||
// // 在Docker容器内,FFmpeg通常在PATH中,所以直接用 "ffmpeg" 即可
|
||||
|
||||
// // 根据图片数量调整每张图片显示时长
|
||||
// if (request.ImageUrls.Count <= 3)
|
||||
// {
|
||||
// request.ImageDurationPerSecond = 3;
|
||||
// }
|
||||
// if (request.ImageUrls.Count > 20)
|
||||
// {
|
||||
// request.ImageDurationPerSecond = 2;
|
||||
// }
|
||||
// // 3. (可选)自定义视频参数
|
||||
// _fFmpegHelper.VideoWidth = 1080;
|
||||
// _fFmpegHelper.VideoHeight = 1920;
|
||||
// _fFmpegHelper.ImageDisplayDurationSeconds = request.ImageDurationPerSecond;
|
||||
// _fFmpegHelper.OutputFrameRate = 30;
|
||||
|
||||
// // 4. 创建进度
|
||||
// var progress = new Progress<double>(p =>
|
||||
// {
|
||||
// Console.WriteLine($"进度: {p:F2}%");
|
||||
// });
|
||||
|
||||
// // 5. 执行合成任务
|
||||
// using (var cancellationTokenSource = new CancellationTokenSource())
|
||||
// {
|
||||
// string resultPath = await _fFmpegHelper.CreateVideoFromImagesAndAudioAsync(
|
||||
// rawImages,
|
||||
// rawAudios[0],
|
||||
// outputVideoPath,
|
||||
// request.VideoWidth,
|
||||
// request.VideoHeight,
|
||||
// progress,
|
||||
// cancellationTokenSource.Token);
|
||||
|
||||
// //Console.WriteLine($"视频合成成功!文件已保存至: {resultPath}");
|
||||
// Serilog.Log.Debug($"视频合成成功!文件已保存至: {resultPath}");
|
||||
// }
|
||||
// }
|
||||
// finally
|
||||
// {
|
||||
// // 清理临时目录(无论成功失败)
|
||||
// if (Directory.Exists(tempDir))
|
||||
// {
|
||||
// Directory.Delete(tempDir, recursive: true);
|
||||
// }
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Serilog.Log.Error($"{ex.StackTrace}");
|
||||
// return false;
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 视频合成(优化后:解决 FFmpeg 进程冲突,支持容错重试)
|
||||
/// </summary>
|
||||
public async Task<bool> MergeToVideo(string cookie, string rootPath, MediaMergeRequest request,
|
||||
string outputVideoPath, string fileNamefolder, bool mergeImg2Viedo, bool downImage = false, bool downMp3 = false)
|
||||
{
|
||||
// 输入参数校验(避免无效执行)
|
||||
if (request == null || request.ImageUrls == null || request.ImageUrls.Count == 0)
|
||||
{
|
||||
Log.Error("图片URL列表为空,无法合成视频");
|
||||
return false;
|
||||
}
|
||||
if (mergeImg2Viedo && (request.AudioUrls == null || request.AudioUrls.Count == 0))
|
||||
{
|
||||
Log.Error("合成视频时音频URL列表为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
string tempDir = null;
|
||||
try
|
||||
{
|
||||
// 创建唯一临时目录(避免并发冲突)
|
||||
tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir); // 确保目录存在
|
||||
|
||||
// 1. 下载图片
|
||||
var (rawImages, imageError) = await DownloadMediaAsync(
|
||||
request.ImageUrls, Path.Combine(tempDir, "raw-images"), "image_", "webp", cookie);
|
||||
if (!string.IsNullOrEmpty(imageError) || rawImages == null || rawImages.Length == 0)
|
||||
{
|
||||
Log.Error($"图片下载失败:{imageError ?? "未下载到任何图片"}");
|
||||
return false;
|
||||
}
|
||||
// 保存下载的图片(如果需要)
|
||||
if (downImage)
|
||||
{
|
||||
await SaveDownloadedFilesAsync(rawImages, fileNamefolder, "temp_", "jpg");
|
||||
}
|
||||
|
||||
// 2. 下载音频
|
||||
string[] rawAudios = Array.Empty<string>();
|
||||
if (mergeImg2Viedo)
|
||||
{
|
||||
var (audios, audioError) = await DownloadMediaAsync(
|
||||
request.AudioUrls, Path.Combine(tempDir, "raw-audios"), "audio_", "mp3", cookie);
|
||||
if (!string.IsNullOrEmpty(audioError) || audios == null || audios.Length == 0)
|
||||
{
|
||||
Log.Error($"音频下载失败:{audioError ?? "未下载到任何音频"}");
|
||||
return false;
|
||||
}
|
||||
rawAudios = audios;
|
||||
// 保存下载的音频(如果需要)
|
||||
if (downMp3)
|
||||
{
|
||||
await SaveDownloadedFilesAsync(rawAudios, fileNamefolder, "temp_", "mp3");
|
||||
}
|
||||
}
|
||||
|
||||
// 不合成视频,直接返回成功
|
||||
if (!mergeImg2Viedo)
|
||||
{
|
||||
Log.Debug("不合成视频,直接返回成功");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 调整图片显示时长
|
||||
AdjustImageDuration(request);
|
||||
|
||||
// 4. 容错重试:处理 FFmpeg 进程冲突等临时错误
|
||||
bool mergeSuccess = await RetryOnFfmpegConflictAsync(async () =>
|
||||
{
|
||||
// 并发控制:等待前一个 FFmpeg 进程完成
|
||||
await _ffmpegSemaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
// 每次合成创建独立的 FFmpegHelper 实例(避免状态共享)
|
||||
var ffmpegHelper = new FFmpegHelper();
|
||||
// 配置视频参数(独立实例,无共享冲突)
|
||||
ffmpegHelper.VideoWidth = request.VideoWidth > 0 ? request.VideoWidth : 1080;
|
||||
ffmpegHelper.VideoHeight = request.VideoHeight > 0 ? request.VideoHeight : 1920;
|
||||
ffmpegHelper.ImageDisplayDurationSeconds = request.ImageDurationPerSecond;
|
||||
ffmpegHelper.OutputFrameRate = 30;
|
||||
|
||||
// 进度回调
|
||||
var progress = new Progress<double>(p =>
|
||||
{
|
||||
Log.Debug($"视频合成进度:{p:F2}%");
|
||||
});
|
||||
|
||||
// 超时控制:避免 FFmpeg 进程无限运行(300秒=5分钟)
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300));
|
||||
// 执行合成(确保每个图片都参与)
|
||||
string resultPath = await ffmpegHelper.CreateVideoFromImagesAndAudioAsync(
|
||||
rawImages, // 所有下载的图片(确保无遗漏)
|
||||
rawAudios[0], // 取第一个音频(可根据需求调整)
|
||||
outputVideoPath,
|
||||
ffmpegHelper.VideoWidth,
|
||||
ffmpegHelper.VideoHeight,
|
||||
progress,
|
||||
cts.Token);
|
||||
|
||||
Log.Debug($"视频合成成功!文件路径:{resultPath}");
|
||||
return !string.IsNullOrEmpty(resultPath) && File.Exists(resultPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 释放信号量,允许下一个任务执行
|
||||
_ffmpegSemaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
return mergeSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "视频合成过程中发生未处理异常");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 安全清理临时目录(确保 FFmpeg 进程已释放文件句柄)
|
||||
await SafeCleanTempDirAsync(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 保存下载的文件(图片/音频)到目标目录
|
||||
/// </summary>
|
||||
private async Task SaveDownloadedFilesAsync(string[] sourcePaths, string targetFolder, string fileNamePrefix, string defaultExt)
|
||||
{
|
||||
if (sourcePaths == null || sourcePaths.Length == 0) return;
|
||||
Directory.CreateDirectory(targetFolder); // 确保目标目录存在
|
||||
|
||||
// 异步保存(不阻塞主线程)
|
||||
await Task.WhenAll(sourcePaths.Select(async (sourcePath, index) =>
|
||||
{
|
||||
if (!File.Exists(sourcePath)) return;
|
||||
string extension = Path.GetExtension(sourcePath) ?? $".{defaultExt}";
|
||||
string destFileName = $"{fileNamePrefix}{index + 1:D3}{extension}";
|
||||
string destPath = Path.Combine(targetFolder, destFileName);
|
||||
|
||||
// 避免文件覆盖,同时确保文件名唯一
|
||||
if (File.Exists(destPath))
|
||||
{
|
||||
destFileName = $"{fileNamePrefix}{index + 1:D3}_{Guid.NewGuid().ToString("N")}{extension}";
|
||||
destPath = Path.Combine(targetFolder, destFileName);
|
||||
}
|
||||
|
||||
// 复制文件(异步避免阻塞)
|
||||
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: false));
|
||||
Log.Debug($"已保存文件:{destPath}");
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FFmpeg 进程冲突时重试
|
||||
/// </summary>
|
||||
private async Task<bool> RetryOnFfmpegConflictAsync(Func<Task<bool>> action)
|
||||
{
|
||||
int retryCount = 0;
|
||||
while (retryCount < RetryCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 识别 FFmpeg 进程冲突相关异常(根据实际异常信息调整条件)
|
||||
if (ex.Message.Contains("FFmpeg 进程正在运行") ||
|
||||
ex.Message.Contains("进程已在运行") ||
|
||||
ex.Message.Contains("文件被另一个进程占用"))
|
||||
{
|
||||
retryCount++;
|
||||
Log.Warning($"FFmpeg 进程冲突,第 {retryCount}/{RetryCount} 次重试... 异常信息:{ex.Message}");
|
||||
await Task.Delay(RetryDelay * retryCount); // 重试间隔递增
|
||||
continue;
|
||||
}
|
||||
// 非进程冲突异常,直接抛出
|
||||
throw;
|
||||
}
|
||||
}
|
||||
Log.Error($"FFmpeg 进程冲突,重试 {RetryCount} 次后仍失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 安全清理临时目录(避免文件被占用)
|
||||
/// </summary>
|
||||
private async Task SafeCleanTempDirAsync(string tempDir)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tempDir) || !Directory.Exists(tempDir))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// 延迟清理:给 FFmpeg 进程足够时间释放文件句柄(1秒)
|
||||
await Task.Delay(1000);
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
Log.Debug($"临时目录已清理:{tempDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 清理失败时备份目录,避免占用磁盘空间
|
||||
string backupDir = $"{tempDir}_backup_{Guid.NewGuid().ToString("N")}";
|
||||
Directory.Move(tempDir, backupDir);
|
||||
Log.Error(ex, $"临时目录清理失败,已备份至:{backupDir}");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 调整图片显示时长
|
||||
/// </summary>
|
||||
private void AdjustImageDuration(MediaMergeRequest request)
|
||||
{
|
||||
if (request.ImageUrls.Count <= 3)
|
||||
request.ImageDurationPerSecond = 3;
|
||||
else if (request.ImageUrls.Count > 20)
|
||||
request.ImageDurationPerSecond = 2;
|
||||
// 中间数量保持原有配置
|
||||
}
|
||||
|
||||
|
||||
/// <summary>通用媒体下载方法</summary>
|
||||
private async Task<(string[] SuccessPaths, string ErrorMsg)> DownloadMediaAsync(
|
||||
List<string> urls, string saveDir, string prefix, string ext,string cookie)
|
||||
{
|
||||
var successPaths = new List<string>();
|
||||
for (var i = 0; i < urls.Count; i++)
|
||||
{
|
||||
var url = urls[i];
|
||||
var fileExt = ext ?? Path.GetExtension(url).TrimStart('.') ?? "png";
|
||||
var fileName = $"{prefix}{i + 1}.{fileExt}";
|
||||
var savePath = Path.Combine(saveDir, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
await douyinHttpClientService.DownloadAsync(url, savePath, cookie);
|
||||
successPaths.Add(savePath);
|
||||
//Console.WriteLine($"下载成功:{url} → {savePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = $"下载失败:{url},错误:{ex.Message}";
|
||||
Serilog.Log.Error(error);
|
||||
return (Array.Empty<string>(), error);
|
||||
}
|
||||
}
|
||||
return (successPaths.ToArray(), null);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,201 +1,303 @@
|
||||
using Quartz;
|
||||
using dy.net.dto;
|
||||
using dy.net.job;
|
||||
using Quartz;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.net.service
|
||||
{
|
||||
/// <summary>
|
||||
/// 抖音相关定时任务服务
|
||||
/// </summary>
|
||||
public class DouyinQuartzJobService
|
||||
{
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private const string DefaultJobGroup = "group1";
|
||||
private const int DefaultIntervalMinutes = 30;
|
||||
private const int DefaultCronStartDelaySeconds = 30;
|
||||
private const int DefaultSimpleStartDelaySeconds = 3;
|
||||
|
||||
|
||||
|
||||
public DouyinQuartzJobService(ISchedulerFactory schedulerFactory)
|
||||
{
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// 启动所有抖音相关定时任务
|
||||
/// </summary>
|
||||
/// <param name="expression"></param>
|
||||
/// <param name="delay"></param>
|
||||
/// <returns></returns>
|
||||
public async Task StartJob(string expression,int delay=5000)
|
||||
/// <param name="expression">Cron表达式或间隔分钟数</param>
|
||||
/// <param name="delayBetweenJobs">任务之间的启动延迟(毫秒)</param>
|
||||
/// <returns>是否启动成功</returns>
|
||||
public async Task<bool> InitOrReStartAllJobs(string expression, int delayBetweenJobs = 5000)
|
||||
{
|
||||
await StartCollectJob(expression);
|
||||
|
||||
await Task.Delay(delay);
|
||||
//如果是数字则加1分钟,减少并发
|
||||
if (int.TryParse(expression, out int cron))
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
cron++;
|
||||
expression = cron.ToString();
|
||||
Log.Warning("定时任务表达式为空,使用默认配置");
|
||||
expression = DefaultIntervalMinutes.ToString();
|
||||
}
|
||||
await StartFavoriteJob(expression);
|
||||
|
||||
await Task.Delay(delay);
|
||||
if (int.TryParse(expression, out int cron2))
|
||||
// 按顺序启动任务,避免并发
|
||||
var jobTasks = new List<Task<bool>>
|
||||
{
|
||||
cron2++;
|
||||
expression = cron2.ToString();
|
||||
}
|
||||
await StartUperPostJob(expression);
|
||||
//我收藏的作品
|
||||
StartJobAsync("collect", expression),
|
||||
//我喜欢的作品
|
||||
DelayAndStartJobAsync("favorite", expression, delayBetweenJobs),
|
||||
//关注的用户的作品
|
||||
DelayAndStartJobAsync("uper", expression, delayBetweenJobs * 2),
|
||||
//关注列表
|
||||
DelayAndStartJobAsync("follow_user", expression, delayBetweenJobs * 3)
|
||||
};
|
||||
|
||||
var results = await Task.WhenAll(jobTasks);
|
||||
return results.All(success => success);
|
||||
}
|
||||
|
||||
private async Task<bool> StartCollectJob(string expression)
|
||||
/// <summary>
|
||||
/// 启动关注同步任务(单次执行)
|
||||
/// </summary>
|
||||
/// <returns>是否启动成功</returns>
|
||||
public async Task<bool> StartFollowJobOnceAsync()
|
||||
{
|
||||
return await StartOneTimeJobAsync("follow_user_once");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 延迟后启动任务
|
||||
/// </summary>
|
||||
private async Task<bool> DelayAndStartJobAsync(string jobKey, string expression, int delayMs)
|
||||
{
|
||||
if (delayMs > 0)
|
||||
{
|
||||
await Task.Delay(delayMs);
|
||||
}
|
||||
|
||||
// 如果是数字表达式,自动递增避免并发
|
||||
var adjustedExpression = AdjustExpressionForConcurrency(jobKey, expression);
|
||||
return await StartJobAsync(jobKey, adjustedExpression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调整任务表达式以避免并发
|
||||
/// </summary>
|
||||
private string AdjustExpressionForConcurrency(string jobKey, string expression)
|
||||
{
|
||||
if (!int.TryParse(expression, out int interval))
|
||||
return expression;
|
||||
|
||||
// 根据任务类型递增间隔,避免所有任务同时执行
|
||||
var jobIndex = _jobConfigs.Keys.ToList().IndexOf(jobKey);
|
||||
return (interval + jobIndex).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动指定定时任务
|
||||
/// </summary>
|
||||
private async Task<bool> StartJobAsync(string configKey, string expression)
|
||||
{
|
||||
if (!_jobConfigs.TryGetValue(configKey, out var jobConfig))
|
||||
{
|
||||
Log.Error("找不到任务配置: {ConfigKey}", configKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var __scheduler1 = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey("dy.job.key.collect", "group1");
|
||||
var triggerKey = new TriggerKey("dy.trigger.key.collect", "group1");
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
|
||||
var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
|
||||
|
||||
var jobDetail = await __scheduler1.GetJobDetail(jobKey);
|
||||
if (jobDetail != null)
|
||||
// 删除已存在的任务
|
||||
await RemoveExistingJobAsync(scheduler, jobKey);
|
||||
|
||||
// 创建任务详情
|
||||
var jobDetail = JobBuilder.Create(jobConfig.JobType)
|
||||
.WithIdentity(jobKey)
|
||||
.WithDescription(jobConfig.Description)
|
||||
.Build();
|
||||
|
||||
// 创建触发器
|
||||
var trigger = CreateTrigger(triggerKey, expression, jobConfig.Description);
|
||||
if (trigger == null)
|
||||
{
|
||||
//await __scheduler1.Shutdown();
|
||||
await __scheduler1.DeleteJob(jobKey);//删掉原来的
|
||||
Log.Error("创建触发器失败: {JobDescription}", jobConfig.Description);
|
||||
return false;
|
||||
}
|
||||
|
||||
IJobDetail job = JobBuilder.Create<DouyinCollectSyncJob>()
|
||||
.WithIdentity(jobKey)
|
||||
.Build();
|
||||
ITrigger trigger;
|
||||
//var expression = Appsettings.Get("Interval");
|
||||
if (CronExpression.IsValidExpression(expression))
|
||||
{
|
||||
trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithCronSchedule(expression)
|
||||
.StartAt(DateTime.Now.AddSeconds(30))
|
||||
.StartNow()
|
||||
.Build();
|
||||
}
|
||||
else
|
||||
{
|
||||
int Interval = int.TryParse(expression, out int _Interval) ? _Interval : 30;
|
||||
trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
//.StartNow()
|
||||
.StartAt(DateTime.Now.AddSeconds(3))
|
||||
.WithSimpleSchedule(x => x
|
||||
.WithIntervalInMinutes(Interval)
|
||||
.RepeatForever())
|
||||
.Build();
|
||||
}
|
||||
// Tell Quartz to schedule the job using our trigger
|
||||
await __scheduler1.ScheduleJob(job, trigger);
|
||||
// 调度任务
|
||||
await scheduler.ScheduleJob(jobDetail, trigger);
|
||||
Log.Information("启动定时任务成功 - {JobDescription}, 表达式: {Expression}",
|
||||
jobConfig.Description, expression);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error("start dy.collect job error", ex);
|
||||
Log.Error(ex, "启动定时任务失败 - {JobDescription}", jobConfig.Description);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> StartFavoriteJob(string expression)
|
||||
/// <summary>
|
||||
/// 启动单次执行任务
|
||||
/// </summary>
|
||||
private async Task<bool> StartOneTimeJobAsync(string configKey)
|
||||
{
|
||||
if (!_jobConfigs.TryGetValue(configKey, out var jobConfig))
|
||||
{
|
||||
Log.Error("找不到任务配置: {ConfigKey}", configKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var __scheduler1 = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey("dy.job.key.favorite", "group1");
|
||||
var triggerKey = new TriggerKey("dy.trigger.key.favorite", "group1");
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
|
||||
var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
|
||||
|
||||
var jobDetail = await __scheduler1.GetJobDetail(jobKey);
|
||||
if (jobDetail != null)
|
||||
{
|
||||
//await __scheduler1.Shutdown();
|
||||
await __scheduler1.DeleteJob(jobKey);//删掉原来的
|
||||
}
|
||||
// 删除已存在的任务
|
||||
await RemoveExistingJobAsync(scheduler, jobKey);
|
||||
|
||||
IJobDetail job = JobBuilder.Create<DouyinFavoritSyncJob>()
|
||||
.WithIdentity(jobKey)
|
||||
.Build();
|
||||
ITrigger trigger;
|
||||
//var expression = Appsettings.Get("Interval");
|
||||
if (CronExpression.IsValidExpression(expression))
|
||||
{
|
||||
trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithCronSchedule(expression)
|
||||
.StartAt(DateTime.Now.AddSeconds(30))
|
||||
.StartNow()
|
||||
.Build();
|
||||
}
|
||||
else
|
||||
{
|
||||
int Interval = int.TryParse(expression, out int _Interval) ? _Interval : 30;
|
||||
trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
//.StartNow()
|
||||
.StartAt(DateTime.Now.AddSeconds(3))
|
||||
.WithSimpleSchedule(x => x
|
||||
.WithIntervalInMinutes(Interval)
|
||||
.RepeatForever())
|
||||
.Build();
|
||||
}
|
||||
// Tell Quartz to schedule the job using our trigger
|
||||
await __scheduler1.ScheduleJob(job, trigger);
|
||||
// 创建任务详情
|
||||
var jobDetail = JobBuilder.Create(jobConfig.JobType)
|
||||
.WithIdentity(jobKey)
|
||||
.WithDescription(jobConfig.Description)
|
||||
.Build();
|
||||
|
||||
// 创建立即执行的触发器(只执行一次)
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithDescription($"{jobConfig.Description} - 单次执行")
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
// 调度任务
|
||||
await scheduler.ScheduleJob(jobDetail, trigger);
|
||||
Log.Information("启动单次任务成功 - {JobDescription}", jobConfig.Description);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error("start dy.favorite job error", ex);
|
||||
Log.Error(ex, "启动单次任务失败 - {JobDescription}", jobConfig.Description);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> StartUperPostJob(string expression)
|
||||
/// <summary>
|
||||
/// 创建触发器(支持Cron表达式和简单间隔)
|
||||
/// </summary>
|
||||
private ITrigger? CreateTrigger(TriggerKey triggerKey, string expression, string jobDescription)
|
||||
{
|
||||
try
|
||||
// Cron表达式格式
|
||||
if (CronExpression.IsValidExpression(expression))
|
||||
{
|
||||
var __scheduler1 = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey("dy.job.key.uper", "group1");
|
||||
var triggerKey = new TriggerKey("dy.trigger.key.uper", "group1");
|
||||
return TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithDescription($"{jobDescription} - Cron调度")
|
||||
.WithCronSchedule(expression)
|
||||
.StartAt(DateTime.Now.AddSeconds(DefaultCronStartDelaySeconds))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var jobDetail = await __scheduler1.GetJobDetail(jobKey);
|
||||
if (jobDetail != null)
|
||||
{
|
||||
//await __scheduler1.Shutdown();
|
||||
await __scheduler1.DeleteJob(jobKey);//删掉原来的
|
||||
}
|
||||
// 数字间隔格式(分钟)
|
||||
if (int.TryParse(expression, out int intervalMinutes))
|
||||
{
|
||||
intervalMinutes = Math.Max(1, intervalMinutes); // 最小间隔1分钟
|
||||
return TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithDescription($"{jobDescription} - 间隔{intervalMinutes}分钟")
|
||||
.StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds))
|
||||
.WithSimpleSchedule(x => x
|
||||
.WithIntervalInMinutes(intervalMinutes)
|
||||
.RepeatForever())
|
||||
.Build();
|
||||
}
|
||||
|
||||
IJobDetail job = JobBuilder.Create<DouyinUperPostSyncJob>()
|
||||
.WithIdentity(jobKey)
|
||||
.Build();
|
||||
ITrigger trigger;
|
||||
//var expression = Appsettings.Get("Interval");
|
||||
if (CronExpression.IsValidExpression(expression))
|
||||
{
|
||||
trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithCronSchedule(expression)
|
||||
.StartAt(DateTime.Now.AddSeconds(30))
|
||||
.StartNow()
|
||||
.Build();
|
||||
}
|
||||
else
|
||||
{
|
||||
int Interval = int.TryParse(expression, out int _Interval) ? _Interval : 30;
|
||||
trigger = TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
//.StartNow()
|
||||
.StartAt(DateTime.Now.AddSeconds(3))
|
||||
.WithSimpleSchedule(x => x
|
||||
.WithIntervalInMinutes(Interval)
|
||||
// 无效表达式,使用默认配置
|
||||
Log.Warning("无效的任务表达式: {Expression},使用默认间隔{DefaultMinutes}分钟",
|
||||
expression, DefaultIntervalMinutes);
|
||||
|
||||
return TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithDescription($"{jobDescription} - 默认间隔调度")
|
||||
.StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds))
|
||||
.WithSimpleSchedule(x => x
|
||||
.WithIntervalInMinutes(DefaultIntervalMinutes)
|
||||
.RepeatForever())
|
||||
.Build();
|
||||
}
|
||||
// Tell Quartz to schedule the job using our trigger
|
||||
await __scheduler1.ScheduleJob(job, trigger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error("start dy.uper job error", ex);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除已存在的任务
|
||||
/// </summary>
|
||||
private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
|
||||
{
|
||||
if (await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
Log.Information("移除已存在的任务: {JobKey}", jobKey);
|
||||
await scheduler.DeleteJob(jobKey);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 任务配置信息
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, JobConfig> _jobConfigs = new()
|
||||
{
|
||||
{
|
||||
"collect",
|
||||
new JobConfig(
|
||||
typeof(DouyinCollectSyncJob),
|
||||
"dy.job.key.collect",
|
||||
"dy.trigger.key.collect",
|
||||
"抖音收藏同步任务")
|
||||
},
|
||||
{
|
||||
"favorite",
|
||||
new JobConfig(
|
||||
typeof(DouyinFavoritSyncJob),
|
||||
"dy.job.key.favorite",
|
||||
"dy.trigger.key.favorite",
|
||||
"抖音点赞同步任务")
|
||||
},
|
||||
{
|
||||
"uper",
|
||||
new JobConfig(
|
||||
typeof(DouyinFollowedViedoSyncJob),
|
||||
"dy.job.key.uper",
|
||||
"dy.trigger.key.uper",
|
||||
"抖音UP主作品同步任务")
|
||||
},
|
||||
{
|
||||
"follow_user",
|
||||
new JobConfig(
|
||||
typeof(DouyinFollowedUsersSyncJob),
|
||||
"dy.job.key.follow_user",
|
||||
"dy.trigger.key.follow_user",
|
||||
"抖音关注同步任务")
|
||||
},
|
||||
{
|
||||
"follow_user_once",
|
||||
new JobConfig(
|
||||
typeof(DouyinFollowedUsersSyncJob),
|
||||
"dy.job.key.follow_user_once",
|
||||
"dy.trigger.key.follow_user_once",
|
||||
"抖音关注同步任务(单次执行)")
|
||||
}
|
||||
,
|
||||
//{
|
||||
// "redown_once",
|
||||
// new JobConfig(
|
||||
// typeof(DouyinReDownSyncJob),
|
||||
// "dy.job.key.redown_once",
|
||||
// "dy.trigger.key.redown_once",
|
||||
// "抖音重新下载任务(单次执行)")
|
||||
//}
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using dy.net.dto;
|
||||
using ClockSnowFlake;
|
||||
using dy.net.dto;
|
||||
using dy.net.model;
|
||||
using dy.net.repository;
|
||||
using dy.net.utils;
|
||||
@@ -11,10 +12,12 @@ namespace dy.net.service
|
||||
{
|
||||
|
||||
private readonly DouyinVideoRepository _dyCollectVideoRepository;
|
||||
private readonly DouyinCookieRepository douyinCookieRepository;
|
||||
|
||||
public DouyinVideoService(DouyinVideoRepository dyCollectVideoRepository)
|
||||
public DouyinVideoService(DouyinVideoRepository dyCollectVideoRepository, DouyinCookieRepository douyinCookieRepository)
|
||||
{
|
||||
_dyCollectVideoRepository = dyCollectVideoRepository;
|
||||
this.douyinCookieRepository = douyinCookieRepository;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +85,7 @@ namespace dy.net.service
|
||||
};
|
||||
if (data.GraphicVideoSize == "0.00")
|
||||
{
|
||||
if(list.Where(x => x.ViedoType == VideoTypeEnum.ImageVideo).Sum(x => x.FileSize) > 0)
|
||||
if (list.Where(x => x.ViedoType == VideoTypeEnum.ImageVideo).Sum(x => x.FileSize) > 0)
|
||||
{
|
||||
data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户
|
||||
}
|
||||
@@ -91,12 +94,24 @@ namespace dy.net.service
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
//分页查询
|
||||
|
||||
public async Task<(List<DouyinVideo> list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, string author = null, string viedoType = null, List<string>? dates = null)
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="awemeId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<DouyinVideo> GetByAwemeId(string awemeId)
|
||||
{
|
||||
return await _dyCollectVideoRepository.GetPagedAsync(pageIndex, pageSize, tag, author, viedoType, dates);
|
||||
return await _dyCollectVideoRepository.GetFirstAsync(x => x.AwemeId == awemeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(List<DouyinVideo> list, int totalCount)> GetPagedAsync(DouyinVideoPageRequestDto dto)
|
||||
{
|
||||
return await _dyCollectVideoRepository.GetPagedAsync(dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +137,155 @@ namespace dy.net.service
|
||||
return await _dyCollectVideoRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新下载选中的视频
|
||||
/// </summary>
|
||||
/// <param name="dto">重新下载请求DTO(包含待处理视频ID列表)</param>
|
||||
/// <returns>是否执行成功(true=流程执行完成,false=无有效数据或执行失败)</returns>
|
||||
/// <exception cref="ArgumentNullException">DTO或ID列表为空时抛出</exception>
|
||||
/// <exception cref="IOException">文件操作失败时抛出(可根据业务调整处理方式)</exception>
|
||||
public async Task<bool> ReDownloadViedoAsync(ReDownViedoDto dto)
|
||||
{
|
||||
// 1. 严格参数校验(避免无效流程)
|
||||
if (dto == null)
|
||||
throw new ArgumentNullException(nameof(dto), "重新下载请求DTO不能为空");
|
||||
if (dto.Ids == null || !dto.Ids.Any())
|
||||
{
|
||||
Serilog.Log.Error("重新下载视频失败:待处理视频ID列表为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 查询有效视频记录(去重+非空校验,避免无效处理)
|
||||
var videoIds = dto.Ids.Distinct().ToList(); // 去重,减少数据库查询和操作
|
||||
var videos = await _dyCollectVideoRepository.GetByIds(videoIds);
|
||||
if (videos == null || !videos.Any())
|
||||
{
|
||||
Serilog.Log.Debug("未查询到有效视频记录:Ids={0}", string.Join(",", videoIds));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 构建重新下载记录(提前准备数据,避免事务内耗时操作)
|
||||
var reDownList = new List<ViedoReDown>();
|
||||
var filePathsToDelete = new List<string>(); // 收集待删除文件路径,统一处理
|
||||
|
||||
foreach (var video in videos)
|
||||
{
|
||||
// 跳过无保存路径的视频(避免无效文件操作)
|
||||
if (string.IsNullOrWhiteSpace(video.VideoSavePath))
|
||||
{
|
||||
Serilog.Log.Debug("视频无保存路径,跳过文件删除:VideoId={0}", video.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建重新下载记录
|
||||
reDownList.Add(new ViedoReDown
|
||||
{
|
||||
Id = IdGener.GetLong().ToString(),
|
||||
CreateTime = DateTime.UtcNow, // 统一使用UTC时间,避免时区问题
|
||||
Status = 0, // 0=待下载(建议用枚举替代魔法值)
|
||||
SavePath = video.VideoSavePath,
|
||||
ViedoId = video.AwemeId,
|
||||
CookieId = video.CookieId
|
||||
});
|
||||
|
||||
filePathsToDelete.Add(video.VideoSavePath);
|
||||
}
|
||||
|
||||
// 无有效重新下载记录时直接返回
|
||||
if (!reDownList.Any())
|
||||
{
|
||||
Serilog.Log.Debug("无有效重新下载记录需要创建:VideoIds={0}", string.Join(",", videoIds));
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 4. 数据库操作(事务保证一致性:创建重新下载记录 + 删除原视频记录必须同时成功/失败)
|
||||
var transactionResult = await _dyCollectVideoRepository.UseTranAsync(async () =>
|
||||
{
|
||||
// 4.1 批量插入重新下载记录(SqlSugar批量插入效率更高)
|
||||
_dyCollectVideoRepository.InsertReDowns(reDownList);
|
||||
// 4.2 批量删除原视频记录(使用视频实际存在的ID,避免无效删除)
|
||||
var actualDeleteIds = videos.Select(v => v.Id).ToList();
|
||||
var deleteCount = await _dyCollectVideoRepository.DeleteByIdsAsync(actualDeleteIds); // 建议仓储层提供异步删除方法
|
||||
}, e =>
|
||||
{
|
||||
Serilog.Log.Error(e, "数据库事务执行失败:Ids={0}", string.Join(",", videoIds));
|
||||
});
|
||||
|
||||
// 5. 文件删除(非事务操作,失败不回滚数据库,可根据业务调整)
|
||||
// 采用异步文件操作,避免同步IO阻塞线程(需.NET 5+支持)
|
||||
foreach (var path in filePathsToDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path); // 异步删除,提升并发性能
|
||||
Serilog.Log.Debug("视频文件删除成功:Path={0}", path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Serilog.Log.Error("视频文件不存在,跳过删除:Path={0}", path);
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "视频文件删除失败:Path={0}", path);
|
||||
}
|
||||
}
|
||||
|
||||
var CookieIds = reDownList.Select(x => x.CookieId).Distinct();
|
||||
foreach (var ck in CookieIds)
|
||||
{
|
||||
var cookie= douyinCookieRepository.GetById(ck);
|
||||
if (cookie == null)
|
||||
continue;
|
||||
var viedoTypes = videos.Where(x => x.CookieId == ck).Select(x => x.ViedoType).Distinct();
|
||||
|
||||
if(viedoTypes!=null&& viedoTypes.Any())
|
||||
{
|
||||
foreach (VideoTypeEnum item in viedoTypes)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case VideoTypeEnum.Favorite:
|
||||
cookie.FavHasSyncd = 0;
|
||||
break;
|
||||
case VideoTypeEnum.Collect:
|
||||
cookie.CollHasSyncd = 0;
|
||||
break;
|
||||
case VideoTypeEnum.UperPost:
|
||||
cookie.UperSyncd = 0;
|
||||
break;
|
||||
case VideoTypeEnum.ImageVideo:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await douyinCookieRepository.UpdateAsync(cookie);
|
||||
|
||||
}
|
||||
Serilog.Log.Debug("重新下载视频流程执行完成:成功创建{0}条重新下载记录,删除{1}个文件,等待重新下载...", reDownList.Count, filePathsToDelete.Count);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "重新下载视频执行失败:Ids={0}", string.Join(",", videoIds));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取待重新下载的视频列表
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<ViedoReDown>> GetViedoReDowns()
|
||||
{
|
||||
return await _dyCollectVideoRepository.GetViedoReDowns();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ namespace dy.net.utils
|
||||
return Sm3ToArray(Sm3ToArray(method + _endString));
|
||||
}
|
||||
|
||||
// ... 其他成员和初始化代码 ...
|
||||
|
||||
|
||||
public int[] Sm3ToArray(string data)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using System.Text;
|
||||
|
||||
namespace dy.net.utils
|
||||
{
|
||||
public static class StringProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 将字符串转换为字符数组 (ASCII)
|
||||
/// </summary>
|
||||
public static int[] ToOrdArray(string s)
|
||||
{
|
||||
return s.Select(c => (int)c).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将整数数组转回字符串
|
||||
/// </summary>
|
||||
public static string ToCharStr(int[] arr)
|
||||
{
|
||||
return new string(arr.Select(i => (char)i).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JavaScript 无符号右移操作 (>>>)
|
||||
/// </summary>
|
||||
public static int JsShiftRight(int value, int n)
|
||||
{
|
||||
uint uValue = (uint)value;
|
||||
return (int)(uValue >> n);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成伪随机混淆字节字符串 (长度为 length * 4)
|
||||
/// </summary>
|
||||
private static Random _random = new Random();
|
||||
public static string GenerateRandomBytes(int length = 3)
|
||||
{
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
int rd = _random.Next(10000);
|
||||
|
||||
result.Append((char)(((rd & 255) & 170) | 1));
|
||||
result.Append((char)(((rd & 255) & 85) | 2));
|
||||
result.Append((char)((JsShiftRight(rd, 8) & 170) | 5));
|
||||
result.Append((char)((JsShiftRight(rd, 8) & 85) | 40));
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class CryptoUtility
|
||||
{
|
||||
public string Salt { get; set; }
|
||||
public List<string> Base64Alphabet { get; set; }
|
||||
|
||||
private readonly int[] _bigArray = {
|
||||
121, 243, 55, 234, 103, 36, 47, 228, 30, 231, 106, 6, 115, 95, 78, 101,
|
||||
250, 207, 198, 50, 139, 227, 220, 105, 97, 143, 34, 28, 194, 215, 18, 100,
|
||||
159, 160, 43, 8, 169, 217, 180, 120, 247, 45, 90, 11, 27, 197, 46, 3,
|
||||
84, 72, 5, 68, 62, 56, 221, 75, 144, 79, 73, 161, 178, 81, 64, 187,
|
||||
134, 117, 186, 118, 16, 241, 130, 71, 89, 147, 122, 129, 65, 40, 88, 150,
|
||||
110, 219, 199, 255, 181, 254, 48, 4, 195, 248, 208, 32, 116, 167, 69, 201,
|
||||
17, 124, 125, 104, 96, 83, 80, 127, 236, 108, 154, 126, 204, 15, 20, 135,
|
||||
112, 158, 13, 1, 188, 164, 210, 237, 222, 98, 212, 77, 253, 42, 170, 202,
|
||||
26, 22, 29, 182, 251, 10, 173, 152, 58, 138, 54, 141, 185, 33, 157, 31,
|
||||
252, 132, 233, 235, 102, 196, 191, 223, 240, 148, 39, 123, 92, 82, 128, 109,
|
||||
57, 24, 38, 113, 209, 245, 2, 119, 153, 229, 189, 214, 230, 174, 232, 63,
|
||||
52, 205, 86, 140, 66, 175, 111, 171, 246, 133, 238, 193, 99, 60, 74, 91,
|
||||
225, 51, 76, 37, 145, 211, 166, 151, 213, 206, 0, 200, 244, 176, 218, 44,
|
||||
184, 172, 49, 216, 93, 168, 53, 21, 183, 41, 67, 85, 224, 155, 226, 242,
|
||||
87, 177, 146, 70, 190, 12, 162, 19, 137, 114, 25, 165, 163, 192, 23, 59,
|
||||
9, 94, 179, 107, 35, 7, 142, 131, 239, 203, 149, 136, 61, 249, 14, 156
|
||||
};
|
||||
|
||||
public CryptoUtility(string salt, List<string> base64Alphabet)
|
||||
{
|
||||
Salt = salt;
|
||||
Base64Alphabet = base64Alphabet;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算 SM3 哈希并返回 byte 数组(即整数列表)
|
||||
/// </summary>
|
||||
public static byte[] Sm3Hash(byte[] input)
|
||||
{
|
||||
var digest = new SM3Digest();
|
||||
digest.BlockUpdate(input, 0, input.Length);
|
||||
byte[] output = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对输入数据计算 SM3 哈希,并返回整数数组
|
||||
/// </summary>
|
||||
public int[] Sm3ToArray(object input)
|
||||
{
|
||||
byte[] bytes;
|
||||
|
||||
if (input is string str)
|
||||
{
|
||||
bytes = Encoding.UTF8.GetBytes(str);
|
||||
}
|
||||
else if (input is int[] arr)
|
||||
{
|
||||
bytes = arr.Select(b => (byte)b).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Input must be string or int[]");
|
||||
}
|
||||
|
||||
byte[] hash = Sm3Hash(bytes);
|
||||
return hash.Select(b => (int)b).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加盐值
|
||||
/// </summary>
|
||||
public string AddSalt(string param)
|
||||
{
|
||||
return param + Salt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理参数(可选加盐)
|
||||
/// </summary>
|
||||
public object ProcessParam(object param, bool addSalt)
|
||||
{
|
||||
if (param is string s && addSalt)
|
||||
{
|
||||
return AddSalt(s);
|
||||
}
|
||||
return param;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取参数哈希数组(双重哈希)
|
||||
/// </summary>
|
||||
public int[] ParamsToArray(object param, bool addSalt = true)
|
||||
{
|
||||
var processed = ProcessParam(param, addSalt);
|
||||
var firstHash = Sm3ToArray(processed);
|
||||
return Sm3ToArray(firstHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RC4 加密
|
||||
/// </summary>
|
||||
public static byte[] Rc4Encrypt(byte[] key, string plaintext)
|
||||
{
|
||||
byte[] data = Encoding.UTF8.GetBytes(plaintext);
|
||||
var rc4 = new RC4Engine();
|
||||
rc4.Init(true, new KeyParameter(key));
|
||||
|
||||
byte[] output = new byte[data.Length];
|
||||
rc4.ProcessBytes(data, 0, data.Length, output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自定义 Base64 编码
|
||||
/// </summary>
|
||||
public string Base64Encode(string input, int selectedAlphabet = 0)
|
||||
{
|
||||
string alphabet = Base64Alphabet[selectedAlphabet];
|
||||
var binary = new StringBuilder();
|
||||
|
||||
foreach (char c in input)
|
||||
{
|
||||
binary.Append(Convert.ToString(c, 2).PadLeft(8, '0'));
|
||||
}
|
||||
|
||||
while (binary.Length % 6 != 0)
|
||||
{
|
||||
binary.Append('0');
|
||||
}
|
||||
|
||||
var chunks = new List<int>();
|
||||
for (int i = 0; i < binary.Length; i += 6)
|
||||
{
|
||||
string chunk = binary.ToString(i, Math.Min(6, binary.Length - i));
|
||||
chunks.Add(Convert.ToInt32(chunk, 2));
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
foreach (int index in chunks)
|
||||
{
|
||||
output.Append(alphabet[index]);
|
||||
}
|
||||
|
||||
// Padding
|
||||
int padding = (6 - (binary.Length % 6)) % 6;
|
||||
output.Append('=', padding / 2);
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ABogus 自定义编码逻辑(类似Base64但不同分组)
|
||||
/// </summary>
|
||||
public string AbogusEncode(string input, int selectedAlphabet)
|
||||
{
|
||||
var abogus = new List<char>();
|
||||
string alphabet = Base64Alphabet[selectedAlphabet];
|
||||
|
||||
for (int i = 0; i < input.Length; i += 3)
|
||||
{
|
||||
int n = 0;
|
||||
if (i + 2 < input.Length)
|
||||
{
|
||||
n = (input[i] << 16) | (input[i + 1] << 8) | input[i + 2];
|
||||
}
|
||||
else if (i + 1 < input.Length)
|
||||
{
|
||||
n = (input[i] << 16) | (input[i + 1] << 8);
|
||||
}
|
||||
else
|
||||
{
|
||||
n = input[i] << 16;
|
||||
}
|
||||
|
||||
int[] masks = { 0xFC0000, 0x03F000, 0x0FC0, 0x3F };
|
||||
int[] shifts = { 18, 12, 6, 0 };
|
||||
|
||||
for (int j = 0; j < 4; j++)
|
||||
{
|
||||
if ((j == 2 && i + 1 >= input.Length) || (j == 3 && i + 2 >= input.Length))
|
||||
break;
|
||||
int val = (n & masks[j]) >> shifts[j];
|
||||
abogus.Add(alphabet[val]);
|
||||
}
|
||||
}
|
||||
|
||||
while (abogus.Count % 4 != 0)
|
||||
{
|
||||
abogus.Add('=');
|
||||
}
|
||||
|
||||
return new string(abogus.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字节数组变换加密(RC4-like 流密码)
|
||||
/// </summary>
|
||||
public string TransformBytes(int[] bytesList)
|
||||
{
|
||||
string bytesStr = StringProcessor.ToCharStr(bytesList);
|
||||
var result = new List<char>();
|
||||
|
||||
int indexB = _bigArray[1];
|
||||
int initialValue = 0;
|
||||
int valueE = 0;
|
||||
|
||||
for (int i = 0; i < bytesStr.Length; i++)
|
||||
{
|
||||
char ch = bytesStr[i];
|
||||
int charValue = ch;
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
initialValue = _bigArray[indexB];
|
||||
int sumInitial = indexB + initialValue;
|
||||
|
||||
_bigArray[1] = initialValue;
|
||||
_bigArray[indexB] = indexB;
|
||||
}
|
||||
else
|
||||
{
|
||||
int sumInitial = initialValue + valueE;
|
||||
sumInitial %= _bigArray.Length;
|
||||
valueE = _bigArray[(i + 2) % _bigArray.Length];
|
||||
sumInitial = (indexB + valueE) % _bigArray.Length;
|
||||
initialValue = _bigArray[sumInitial];
|
||||
}
|
||||
|
||||
int sumInitialFinal = (indexB + (i == 0 ? initialValue : valueE)) % _bigArray.Length;
|
||||
int valueF = _bigArray[sumInitialFinal];
|
||||
int encryptedChar = charValue ^ valueF;
|
||||
result.Add((char)encryptedChar);
|
||||
|
||||
// 更新状态
|
||||
valueE = _bigArray[(i + 2) % _bigArray.Length];
|
||||
sumInitialFinal = (indexB + valueE) % _bigArray.Length;
|
||||
int temp = _bigArray[sumInitialFinal];
|
||||
_bigArray[sumInitialFinal] = _bigArray[(i + 2) % _bigArray.Length];
|
||||
_bigArray[(i + 2) % _bigArray.Length] = temp;
|
||||
indexB = sumInitialFinal;
|
||||
}
|
||||
|
||||
return new string(result.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public class BrowserFingerprintGenerator
|
||||
{
|
||||
private static Random _random = new Random();
|
||||
|
||||
public static string GenerateFingerprint(string browserType = "Edge")
|
||||
{
|
||||
return browserType switch
|
||||
{
|
||||
"Chrome" => _GenerateFingerprint("Win32"),
|
||||
"Firefox" => _GenerateFingerprint("Win32"),
|
||||
"Safari" => _GenerateFingerprint("MacIntel"),
|
||||
"Edge" => _GenerateFingerprint("Win32"),
|
||||
_ => _GenerateFingerprint("Win32")
|
||||
};
|
||||
}
|
||||
|
||||
private static string _GenerateFingerprint(string platform)
|
||||
{
|
||||
int innerWidth = _random.Next(1024, 1921);
|
||||
int innerHeight = _random.Next(768, 1081);
|
||||
int outerWidth = innerWidth + _random.Next(24, 33);
|
||||
int outerHeight = innerHeight + _random.Next(75, 91);
|
||||
int screenX = 0;
|
||||
int screenY = _random.Next(2) == 0 ? 0 : 30;
|
||||
int sizeWidth = _random.Next(1024, 1921);
|
||||
int sizeHeight = _random.Next(768, 1081);
|
||||
int availWidth = _random.Next(1280, 1921);
|
||||
int availHeight = _random.Next(800, 1081);
|
||||
|
||||
return $"{innerWidth}|{innerHeight}|{outerWidth}|{outerHeight}|" +
|
||||
$"{screenX}|{screenY}|0|0|{sizeWidth}|{sizeHeight}|" +
|
||||
$"{availWidth}|{availHeight}|{innerWidth}|{innerHeight}|24|24|{platform}";
|
||||
}
|
||||
}
|
||||
|
||||
public class ABogus2
|
||||
{
|
||||
private int aid = 6383;
|
||||
private int pageId = 0;
|
||||
private string salt = "cus";
|
||||
private bool boe = false;
|
||||
private double ddrt = 8.5;
|
||||
private double ic = 8.5;
|
||||
private List<string> paths = new() {
|
||||
"^/webcast/", "^/aweme/v1/", "^/aweme/v2/", "/v1/message/send", "^/live/", "^/captcha/", "^/ecom/"
|
||||
};
|
||||
|
||||
private byte[] uaKey = { 0x00, 0x01, 0x0E };
|
||||
|
||||
private string character = "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe";
|
||||
private string character2 = "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe";
|
||||
|
||||
private List<string> characterList;
|
||||
private CryptoUtility cryptoUtility;
|
||||
|
||||
private string userAgent;
|
||||
private string browserFp;
|
||||
|
||||
private int[] sortIndex = {
|
||||
18, 20, 52, 26, 30, 34, 58, 38, 40, 53, 42, 21, 27, 54, 55, 31, 35, 57, 39, 41, 43, 22, 28,
|
||||
32, 60, 36, 23, 29, 33, 37, 44, 45, 59, 46, 47, 48, 49, 50, 24, 25, 65, 66, 70, 71
|
||||
};
|
||||
|
||||
private int[] sortIndex2 = {
|
||||
18, 20, 26, 30, 34, 38, 40, 42, 21, 27, 31, 35, 39, 41, 43, 22, 28, 32, 36, 23, 29, 33, 37,
|
||||
44, 45, 46, 47, 48, 49, 50, 24, 25, 52, 53, 54, 55, 57, 58, 59, 60, 65, 66, 70, 71
|
||||
};
|
||||
|
||||
public List<int> Options { get; set; } = new() { 0, 1, 14 }; // POST 默认
|
||||
|
||||
public ABogus2(string fp = "", string userAgent = "", List<int> options = null)
|
||||
{
|
||||
if (options != null) Options = options;
|
||||
|
||||
this.userAgent = !string.IsNullOrEmpty(userAgent)
|
||||
? userAgent
|
||||
: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0";
|
||||
|
||||
this.browserFp = !string.IsNullOrEmpty(fp)
|
||||
? fp
|
||||
: BrowserFingerprintGenerator.GenerateFingerprint("Edge");
|
||||
|
||||
characterList = new List<string> { character, character2 };
|
||||
cryptoUtility = new CryptoUtility(salt, characterList);
|
||||
}
|
||||
|
||||
public string EncodeData(string data, int alphabetIndex = 0)
|
||||
{
|
||||
return cryptoUtility.AbogusEncode(data, alphabetIndex);
|
||||
}
|
||||
|
||||
public (string paramsWithAbogus, string abogus, string userAgent, string body) GenerateAbogus(string paramsStr, string body = "")
|
||||
{
|
||||
var abDir = new Dictionary<int, object>
|
||||
{
|
||||
{ 8, 3 },
|
||||
{ 15, new {
|
||||
aid = this.aid,
|
||||
pageId = this.pageId,
|
||||
boe = this.boe,
|
||||
ddrt = this.ddrt,
|
||||
paths = this.paths,
|
||||
track = new { mode = 0, delay = 300, paths = new List<object>() },
|
||||
dump = true,
|
||||
rpU = ""
|
||||
}},
|
||||
{ 18, 44 },
|
||||
{ 19, new[] { 1, 0, 1, 0, 1 } },
|
||||
{ 66, 0 },
|
||||
{ 69, 0 },
|
||||
{ 70, 0 },
|
||||
{ 71, 0 }
|
||||
};
|
||||
|
||||
long startEncryption = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
int[] array1 = cryptoUtility.ParamsToArray(paramsStr); // 双重哈希
|
||||
int[] array2 = body != "" ? cryptoUtility.ParamsToArray(body) : new int[0];
|
||||
string encodedUa = cryptoUtility.Base64Encode(
|
||||
StringProcessor.ToCharStr(
|
||||
Array.ConvertAll(CryptoUtility.Rc4Encrypt(uaKey, userAgent), b => (int)b)
|
||||
),
|
||||
1
|
||||
);
|
||||
|
||||
int[] array3 = cryptoUtility.Sm3ToArray(encodedUa); // 不加盐
|
||||
|
||||
long endEncryption = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
|
||||
// 插入时间戳高位
|
||||
abDir[20] = (byte)((startEncryption >> 24) & 0xFF);
|
||||
abDir[21] = (byte)((startEncryption >> 16) & 0xFF);
|
||||
abDir[22] = (byte)((startEncryption >> 8) & 0xFF);
|
||||
abDir[23] = (byte)(startEncryption & 0xFF);
|
||||
abDir[24] = (int)((startEncryption >> 32) & 0xFF);
|
||||
abDir[25] = (int)((startEncryption >> 40) & 0xFF);
|
||||
|
||||
// 请求选项
|
||||
abDir[26] = (byte)((Options[0] >> 24) & 0xFF);
|
||||
abDir[27] = (byte)((Options[0] >> 16) & 0xFF);
|
||||
abDir[28] = (byte)((Options[0] >> 8) & 0xFF);
|
||||
abDir[29] = (byte)(Options[0] & 0xFF);
|
||||
|
||||
abDir[30] = (byte)((Options[1] >> 8) & 0xFF);
|
||||
abDir[31] = (byte)(Options[1] & 0xFF);
|
||||
abDir[32] = (byte)((Options[1] >> 24) & 0xFF);
|
||||
abDir[33] = (byte)((Options[1] >> 16) & 0xFF);
|
||||
|
||||
abDir[34] = (byte)((Options[2] >> 24) & 0xFF);
|
||||
abDir[35] = (byte)((Options[2] >> 16) & 0xFF);
|
||||
abDir[36] = (byte)((Options[2] >> 8) & 0xFF);
|
||||
abDir[37] = (byte)(Options[2] & 0xFF);
|
||||
|
||||
abDir[38] = array1[21];
|
||||
abDir[39] = array1[22];
|
||||
abDir[40] = array2.Length > 21 ? array2[21] : 0;
|
||||
abDir[41] = array2.Length > 22 ? array2[22] : 0;
|
||||
abDir[42] = array3.Length > 23 ? array3[23] : 0;
|
||||
abDir[43] = array3.Length > 24 ? array3[24] : 0;
|
||||
|
||||
abDir[44] = (byte)((endEncryption >> 24) & 0xFF);
|
||||
abDir[45] = (byte)((endEncryption >> 16) & 0xFF);
|
||||
abDir[46] = (byte)((endEncryption >> 8) & 0xFF);
|
||||
abDir[47] = (byte)(endEncryption & 0xFF);
|
||||
abDir[48] = abDir[8];
|
||||
abDir[49] = (int)((endEncryption >> 32) & 0xFF);
|
||||
abDir[50] = (int)((endEncryption >> 40) & 0xFF);
|
||||
|
||||
abDir[51] = (byte)((pageId >> 24) & 0xFF);
|
||||
abDir[52] = (byte)((pageId >> 16) & 0xFF);
|
||||
abDir[53] = (byte)((pageId >> 8) & 0xFF);
|
||||
abDir[54] = (byte)(pageId & 0xFF);
|
||||
abDir[55] = pageId;
|
||||
abDir[56] = aid;
|
||||
abDir[57] = (byte)(aid & 0xFF);
|
||||
abDir[58] = (byte)((aid >> 8) & 0xFF);
|
||||
abDir[59] = (byte)((aid >> 16) & 0xFF);
|
||||
abDir[60] = (byte)((aid >> 24) & 0xFF);
|
||||
|
||||
abDir[64] = browserFp.Length;
|
||||
abDir[65] = browserFp.Length;
|
||||
|
||||
// 排序取值
|
||||
var sortedValues = sortIndex
|
||||
.Select(k => Convert.ToInt32(abDir.GetValueOrDefault(k, 0)))
|
||||
.ToList();
|
||||
|
||||
var fpArray = StringProcessor.ToOrdArray(browserFp).ToList();
|
||||
|
||||
int abXor = 0;
|
||||
abXor = sortIndex2
|
||||
.Select(k => Convert.ToInt32(abDir.GetValueOrDefault(k, 0)))
|
||||
.Aggregate(0, (x, y) => x ^ y);
|
||||
|
||||
|
||||
sortedValues.AddRange(fpArray);
|
||||
sortedValues.Add(abXor);
|
||||
|
||||
string randomBytes = StringProcessor.GenerateRandomBytes();
|
||||
string transformed = cryptoUtility.TransformBytes(sortedValues.ToArray());
|
||||
|
||||
string abogusBytesStr = randomBytes + transformed;
|
||||
string abogus = cryptoUtility.AbogusEncode(abogusBytesStr, 0);
|
||||
|
||||
string finalParams = $"{paramsStr}&a_bogus={abogus}";
|
||||
|
||||
return (finalParams, abogus, userAgent, body);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试代码
|
||||
//public class ABogusTest
|
||||
//{
|
||||
// //public static void Main()
|
||||
// //{
|
||||
// // string userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0";
|
||||
// // string edgeFp = BrowserFingerprintGenerator.GenerateFingerprint("Edge");
|
||||
// // var abogus = new ABogus2(fp: edgeFp, userAgent: userAgent);
|
||||
|
||||
// // // GET请求测试
|
||||
// // string getParams = "device_platform=webapp&aid=6383&channel=channel_pc_web&sec_user_id=MS4wLjABAAAArDVBosPJF3eIWVEFp0szuJ-e1V_-rK0ieJeWwpE77E8&max_cursor=0&locate_query=false&show_live_replay_strategy=1&need_time_list=1&time_list_query=0&whale_cut_token=&cut_version=1&count=18&publish_video_strategy_type=2&from_user_page=1&update_version_code=170400&pc_client_type=1&pc_libra_divert=Windows&support_h265=1&support_dash=0&version_code=290100&version_name=29.1.0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=Win32&browser_name=Edge&browser_version=131.0.0.0&browser_online=true&engine_name=Blink&engine_version=131.0.0.0&os_name=Windows&os_version=10&cpu_core_num=12&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50";
|
||||
// // var getResult = abogus.GenerateABogus(getParams);
|
||||
// // Console.WriteLine($"GET 完整URL: https://www.douyin.com/aweme/v1/web/aweme/detail/?{getResult.Params}");
|
||||
// // Console.WriteLine($"GET ABogus: {getResult.ABogus}");
|
||||
|
||||
// // // POST请求测试
|
||||
// // string postParams = "device_platform=webapp&aid=6383&channel=channel_pc_web&pc_client_type=1&pc_libra_divert=Windows&update_version_code=170400&support_h265=1&support_dash=0&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=Win32&browser_name=Edge&browser_version=131.0.0.0&browser_online=true&engine_name=Blink&engine_version=131.0.0.0&os_name=Windows&os_version=10&cpu_core_num=12&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50";
|
||||
// // string postBody = "aweme_type=0&item_id=7467485482314763572&play_delta=1&source=0";
|
||||
// // var postResult = abogus.GenerateABogus(postParams, postBody);
|
||||
// // Console.WriteLine($"POST 完整URL: https://www.douyin.com/aweme/v2/web/aweme/stats/?{postResult.Params}");
|
||||
// // Console.WriteLine($"POST ABogus: {postResult.ABogus}");
|
||||
// // Console.WriteLine($"POST Body: {postResult.Body}");
|
||||
// //}
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,95 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace dy.net.utils
|
||||
{
|
||||
public class DouyinBaseParamDics
|
||||
/// <summary>
|
||||
/// 抖音网页端请求参数字典管理类
|
||||
/// 提供各类接口的标准化参数字典,对外暴露的属性名称和返回类型保持不变
|
||||
/// </summary>
|
||||
public static class DouyinBaseParamDics
|
||||
{
|
||||
public static Dictionary<string, string> CollectParams { get; } = InitializeUserCollecParams();
|
||||
/// <summary>
|
||||
/// 收藏列表参数(用户收藏的内容)
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> CollectParams { get; } = InitializeUserCollectParams();
|
||||
|
||||
/// <summary>
|
||||
/// 用户收藏参数(与 CollectParams 功能区分,保留原始定义)
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> FavoriteParams { get; } = InitializeUserFavoriteParams();
|
||||
|
||||
/// <summary>
|
||||
/// 博主发布作品参数
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> UpderPostParams { get; } = InitializeUpderPostParams();
|
||||
|
||||
private static Dictionary<string, string> InitializeUserCollecParams()
|
||||
{
|
||||
var parameters = GetDyBaseParameters();
|
||||
/// <summary>
|
||||
/// 我关注的博主列表参数
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> MyFollowParams { get; } = InitializeMyFollowParams();
|
||||
|
||||
// 覆盖与基础字段不同的值
|
||||
/// <summary>
|
||||
/// 视频详情
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> ViedoDetailParam { get; } = InitializeViedoDetailParam();
|
||||
|
||||
/// <summary>
|
||||
/// 抖音作品列表请求参数(适配接口:https://www.douyin.com/aweme/v1/web/aweme/post/)
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> InitializeDouyinPostParams()
|
||||
{
|
||||
// 基于基础参数扩展,减少冗余
|
||||
var parameters = GetBaseParameters();
|
||||
|
||||
// 覆盖基础参数中不同的值
|
||||
parameters["version_code"] = "290100";
|
||||
parameters["version_name"] = "29.1.0";
|
||||
parameters["screen_width"] = "1707";
|
||||
parameters["screen_height"] = "1067";
|
||||
parameters["browser_name"] = "Chrome";
|
||||
parameters["browser_version"] = "142.0.0.0";
|
||||
parameters["engine_version"] = "142.0.0.0";
|
||||
parameters["cpu_core_num"] = "32";
|
||||
parameters["support_h265"] = "1";
|
||||
parameters["support_dash"] = "1";
|
||||
|
||||
// 添加特有参数
|
||||
parameters.AddRange(new Dictionary<string, string>
|
||||
{
|
||||
{"sec_user_id", ""},
|
||||
{"max_cursor", "0"},
|
||||
{"locate_item_id", "7576282367263807451"},
|
||||
{"locate_query", "false"},
|
||||
{"count", "18"},
|
||||
{"show_live_replay_strategy", "1"},
|
||||
{"need_time_list", "1"},
|
||||
{"time_list_query", "0"},
|
||||
{"publish_video_strategy_type", "2"},
|
||||
{"from_user_page", "1"},
|
||||
{"webid", "7574080345697584675"},
|
||||
{"uifid", ""},
|
||||
{"msToken", ""},
|
||||
{"a_bogus", ""},
|
||||
{"verifyFp", ""},
|
||||
{"fp", ""},
|
||||
{"x-secsdk-web-expire", ""},
|
||||
{"x-secsdk-web-signature", ""},
|
||||
{"cut_version", "1"}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
#region 私有初始化方法
|
||||
|
||||
/// <summary>
|
||||
/// 初始化收藏列表参数
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> InitializeUserCollectParams()
|
||||
{
|
||||
var parameters = GetBaseParameters();
|
||||
|
||||
// 覆盖基础参数
|
||||
parameters["version_code"] = "290100";
|
||||
parameters["version_name"] = "29.1.0";
|
||||
parameters["screen_width"] = "1920";
|
||||
@@ -21,22 +98,27 @@ namespace dy.net.utils
|
||||
parameters["engine_version"] = "130.0.0.0";
|
||||
parameters["cpu_core_num"] = "12";
|
||||
|
||||
// 添加当前字段特有的键值对
|
||||
parameters["from_user_page"] = "1";
|
||||
parameters["locate_query"] = "false";
|
||||
parameters["need_time_list"] = "1";
|
||||
parameters["show_live_replay_strategy"] = "1";
|
||||
parameters["time_list_query"] = "0";
|
||||
// 添加特有参数
|
||||
parameters.AddRange(new Dictionary<string, string>
|
||||
{
|
||||
{"from_user_page", "1"},
|
||||
{"locate_query", "false"},
|
||||
{"need_time_list", "1"},
|
||||
{"show_live_replay_strategy", "1"},
|
||||
{"time_list_query", "0"}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// 初始化用户收藏参数
|
||||
/// <summary>
|
||||
/// 初始化用户收藏参数
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> InitializeUserFavoriteParams()
|
||||
{
|
||||
var parameters = GetDyBaseParameters();
|
||||
var parameters = GetBaseParameters();
|
||||
|
||||
// 覆盖与基础字段不同的值
|
||||
// 覆盖基础参数
|
||||
parameters["version_code"] = "170400";
|
||||
parameters["version_name"] = "17.4.0";
|
||||
parameters["screen_width"] = "1536";
|
||||
@@ -44,154 +126,121 @@ namespace dy.net.utils
|
||||
parameters["browser_version"] = "140.0.0.0";
|
||||
parameters["engine_version"] = "140.0.0.0";
|
||||
parameters["cpu_core_num"] = "20";
|
||||
|
||||
// 添加当前字段特有的键值对
|
||||
parameters["min_cursor"] = "0";
|
||||
parameters["cut_version"] = "1";
|
||||
parameters["count"] = "18";
|
||||
parameters["support_h265"] = "1";
|
||||
parameters["support_dash"] = "1";
|
||||
|
||||
// 添加特有参数
|
||||
parameters.AddRange(new Dictionary<string, string>
|
||||
{
|
||||
{"min_cursor", "0"},
|
||||
{"cut_version", "1"},
|
||||
{"count", "18"}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// 初始化抖音博主发布作品参数
|
||||
private static Dictionary<string,string> InitializeUpderPostParams()
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
{ "WebIdLastTime", "1714385892" },
|
||||
{ "aid", "1988" },
|
||||
{ "app_language", "zh-Hans" },
|
||||
{ "app_name", "tiktok_web" },
|
||||
{ "browser_language", "zh-CN" },
|
||||
{ "browser_name", "Mozilla" },
|
||||
{ "browser_online", "true" },
|
||||
{ "browser_platform", "Win32" },
|
||||
{ "browser_version", "5.0%20%28Windows%29" },
|
||||
{ "cookie_enabled", "true" },
|
||||
{ "count", "18" },
|
||||
{ "coverFormat", "2" },
|
||||
{ "cursor", "0" },
|
||||
{ "data_collection_enabled", "true" },
|
||||
{ "device_id", "7380187414842836523" },
|
||||
{ "device_platform", "webapp" },
|
||||
{ "channel", "channel_pc_web" },
|
||||
{ "focus_state", "true" },
|
||||
{ "from_page", "user" },
|
||||
{ "history_len", "3" },
|
||||
{ "is_fullscreen", "false" },
|
||||
{ "is_page_visible", "true" },
|
||||
{ "language", "zh-Hans" },
|
||||
{ "locate_item_id", "" },
|
||||
{ "needPinnedItemIds", "true" },
|
||||
{ "odinId", "7404669909585003563" },
|
||||
{ "os", "windows" },
|
||||
{ "post_item_list_request_type", "0" },
|
||||
{ "priority_region", "US" },
|
||||
{ "referer", "" },
|
||||
{ "region", "US" },
|
||||
{ "screen_height", "827" },
|
||||
{ "screen_width", "1323" },
|
||||
{ "secUid", "" },
|
||||
{ "tz_name", "America%2FLos_Angeles" },
|
||||
{ "user_is_login", "true" },
|
||||
{ "webcast_language", "zh-Hans" },
|
||||
{ "msToken", "" },
|
||||
{ "_signature", "_02B4Z6wo000017oyWOQAAIDD9xNhTSnfaDu6MFxAAIlj23" },
|
||||
{"sec_user_id",""}
|
||||
};
|
||||
return parameters;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 初始化抖音网页端用户作品列表请求参数(参数来源于目标URL)
|
||||
/// 适配接口:https://www.douyin.com/aweme/v1/web/aweme/post/
|
||||
/// 初始化博主发布作品参数
|
||||
/// </summary>
|
||||
/// <returns>抖音作品列表请求参数字典</returns>
|
||||
public static Dictionary<string, string> InitializeDouyinPostParams()
|
||||
private static Dictionary<string, string> InitializeUpderPostParams()
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
// 该接口参数差异较大,单独初始化(保留原始参数完整)
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
{"WebIdLastTime", "1714385892"},
|
||||
{"aid", "1988"},
|
||||
{"app_language", "zh-Hans"},
|
||||
{"app_name", "tiktok_web"},
|
||||
{"browser_language", "zh-CN"},
|
||||
{"browser_name", "Mozilla"},
|
||||
{"browser_online", "true"},
|
||||
{"browser_platform", "Win32"},
|
||||
{"browser_version", "5.0%20%28Windows%29"},
|
||||
{"cookie_enabled", "true"},
|
||||
{"count", "18"},
|
||||
{"coverFormat", "2"},
|
||||
{"cursor", "0"},
|
||||
{"data_collection_enabled", "true"},
|
||||
{"device_id", "7380187414842836523"},
|
||||
{"device_platform", "webapp"},
|
||||
{"channel", "channel_pc_web"},
|
||||
{"focus_state", "true"},
|
||||
{"from_page", "user"},
|
||||
{"history_len", "3"},
|
||||
{"is_fullscreen", "false"},
|
||||
{"is_page_visible", "true"},
|
||||
{"language", "zh-Hans"},
|
||||
{"locate_item_id", ""},
|
||||
{"needPinnedItemIds", "true"},
|
||||
{"odinId", "7404669909585003563"},
|
||||
{"os", "windows"},
|
||||
{"post_item_list_request_type", "0"},
|
||||
{"priority_region", "US"},
|
||||
{"referer", ""},
|
||||
{"region", "US"},
|
||||
{"screen_height", "827"},
|
||||
{"screen_width", "1323"},
|
||||
{"secUid", ""},
|
||||
{"sec_user_id", ""},
|
||||
{"tz_name", "America%2FLos_Angeles"},
|
||||
{"user_is_login", "true"},
|
||||
{"webcast_language", "zh-Hans"},
|
||||
{"msToken", ""}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化我关注的博主列表参数
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> InitializeMyFollowParams()
|
||||
{
|
||||
// 基础设备与渠道参数
|
||||
{ "device_platform", "webapp" },
|
||||
{ "aid", "6383" },
|
||||
{ "channel", "channel_pc_web" },
|
||||
var parameters = GetBaseParameters();
|
||||
|
||||
// 用户标识参数
|
||||
{ "sec_user_id", "" },
|
||||
// 覆盖基础参数
|
||||
parameters["version_code"] = "170400";
|
||||
parameters["version_name"] = "17.4.0";
|
||||
parameters["screen_width"] = "1707";
|
||||
parameters["screen_height"] = "1067";
|
||||
parameters["browser_name"] = "Edge";
|
||||
parameters["browser_version"] = "141.0.0.0";
|
||||
parameters["engine_version"] = "141.0.0.0";
|
||||
parameters["cpu_core_num"] = "32";
|
||||
parameters["support_h265"] = "1";
|
||||
parameters["support_dash"] = "1";
|
||||
|
||||
// 分页与内容定位参数
|
||||
{ "max_cursor", "0" },
|
||||
{ "locate_item_id", "7576282367263807451" },
|
||||
{ "locate_query", "false" },
|
||||
{ "count", "18" },
|
||||
|
||||
// 视频相关配置参数
|
||||
{ "show_live_replay_strategy", "1" },
|
||||
{ "need_time_list", "1" },
|
||||
{ "time_list_query", "0" },
|
||||
{ "publish_video_strategy_type", "2" },
|
||||
{ "support_h265", "1" },
|
||||
{ "support_dash", "1" },
|
||||
|
||||
// 版本与更新参数
|
||||
{ "from_user_page", "1" },
|
||||
{ "update_version_code", "170400" },
|
||||
{ "version_code", "290100" },
|
||||
{ "version_name", "29.1.0" },
|
||||
|
||||
// PC端特有参数
|
||||
{ "pc_client_type", "1" },
|
||||
{ "pc_libra_divert", "Windows" },
|
||||
{ "cpu_core_num", "32" },
|
||||
|
||||
// 浏览器环境参数
|
||||
{ "browser_language", "zh-CN" },
|
||||
{ "browser_platform", "Win32" },
|
||||
{ "browser_name", "Chrome" },
|
||||
{ "browser_version", "142.0.0.0" },
|
||||
{ "browser_online", "true" },
|
||||
{ "engine_name", "Blink" },
|
||||
{ "engine_version", "142.0.0.0" },
|
||||
|
||||
// 系统环境参数
|
||||
{ "os_name", "Windows" },
|
||||
{ "os_version", "10" },
|
||||
{ "device_memory", "8" },
|
||||
{ "platform", "PC" },
|
||||
{ "cookie_enabled", "true" },
|
||||
|
||||
// 屏幕与网络参数
|
||||
{ "screen_width", "1707" },
|
||||
{ "screen_height", "1067" },
|
||||
{ "downlink", "10" },
|
||||
{ "effective_type", "4g" },
|
||||
{ "round_trip_time", "0" },
|
||||
|
||||
// 用户唯一标识参数
|
||||
{ "webid", "7574080345697584675" },
|
||||
{ "uifid", "" },
|
||||
|
||||
// 安全验证与签名参数
|
||||
{ "msToken", "" },
|
||||
{ "a_bogus", "" },
|
||||
{ "verifyFp", "" },
|
||||
{ "fp", "" },
|
||||
{ "x-secsdk-web-expire", "" },
|
||||
{ "x-secsdk-web-signature", "" },
|
||||
|
||||
// 其他辅助参数
|
||||
{ "whale_cut_token", "" },
|
||||
{ "cut_version", "1" }
|
||||
};
|
||||
// 添加特有参数
|
||||
parameters.AddRange(new Dictionary<string, string>
|
||||
{
|
||||
{"user_id", "1218695735550247"},
|
||||
{"sec_user_id", ""},
|
||||
{"offset", "40"},
|
||||
{"min_time", "0"},
|
||||
{"max_time", "0"},
|
||||
{"count", "20"},
|
||||
{"source_type", "4"},
|
||||
{"gps_access", "0"},
|
||||
{"address_book_access", "0"},
|
||||
{"is_top", "1"},
|
||||
{"webid", "7577203855940994560"},
|
||||
{"uifid", ""},
|
||||
{"msToken", ""},
|
||||
{"a_bogus", ""},
|
||||
{"verifyFp", ""},
|
||||
{"fp", ""}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// 静态基础参数(供内部初始化使用)
|
||||
private static Dictionary<string, string> GetDyBaseParameters()
|
||||
#endregion
|
||||
|
||||
#region 基础参数与扩展方法
|
||||
|
||||
/// <summary>
|
||||
/// 获取抖音网页端基础参数字典(所有接口的公共参数)
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> GetBaseParameters()
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
@@ -199,6 +248,7 @@ namespace dy.net.utils
|
||||
{"aid", "6383"},
|
||||
{"channel", "channel_pc_web"},
|
||||
{"pc_client_type", "1"},
|
||||
{"pc_libra_divert", "Windows"},
|
||||
{"cookie_enabled", "true"},
|
||||
{"browser_language", "zh-CN"},
|
||||
{"browser_platform", "Win32"},
|
||||
@@ -211,13 +261,77 @@ namespace dy.net.utils
|
||||
{"platform", "PC"},
|
||||
{"downlink", "10"},
|
||||
{"effective_type", "4g"},
|
||||
{"pc_libra_divert", "Windows"},
|
||||
{"publish_video_strategy_type", "2"},
|
||||
{"round_trip_time", "0"},
|
||||
{"whale_cut_token", ""},
|
||||
{"update_version_code", "170400"}
|
||||
{"update_version_code", "170400"},
|
||||
{"whale_cut_token", ""}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private static Dictionary<string, string> InitializeViedoDetailParam()
|
||||
{
|
||||
|
||||
Dictionary<string, string> requestParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "device_platform", "webapp" },
|
||||
{ "aid", "6383" },
|
||||
{ "channel", "channel_pc_web" },
|
||||
{ "aweme_id", "" },
|
||||
{ "request_source", "600" },
|
||||
{ "origin_type", "video_page" },
|
||||
{ "update_version_code", "170400" },
|
||||
{ "pc_client_type", "1" },
|
||||
{ "pc_libra_divert", "Windows" },
|
||||
{ "support_h265", "1" },
|
||||
{ "support_dash", "1" },
|
||||
{ "cpu_core_num", "32" },
|
||||
{ "version_code", "190500" },
|
||||
{ "version_name", "19.5.0" },
|
||||
{ "cookie_enabled", "true" },
|
||||
{ "screen_width", "1707" },
|
||||
{ "screen_height", "1067" },
|
||||
{ "browser_language", "zh-CN" },
|
||||
{ "browser_platform", "Win32" },
|
||||
{ "browser_name", "Edge" },
|
||||
{ "browser_version", "141.0.0.0" },
|
||||
{ "browser_online", "true" },
|
||||
{ "engine_name", "Blink" },
|
||||
{ "engine_version", "141.0.0.0" },
|
||||
{ "os_name", "Windows" },
|
||||
{ "os_version", "10" },
|
||||
{ "device_memory", "8" },
|
||||
{ "platform", "PC" },
|
||||
{ "downlink", "10" },
|
||||
{ "effective_type", "4g" },
|
||||
{ "round_trip_time", "0" },
|
||||
{ "webid", "7577203855940994560" },
|
||||
{ "uifid", "0e81ba593d64ebaca259bdbe302de8d7e55ac2e982f7412f10fbc5c77c64bb8b8830ffed112ea8b3bc54f3b6e2af3856daafa4efaafef18953612820da493aeaebdeac0537b99b8e68f343c2476cf6347f7751a87d92952128116470f147215cf46b949f43f31aedcb660fd3c5c7eb2145183cc93d1b4202205b4af7d7c69ab55e860f96e315899a2ee74a262273694ec973ba682bb6fdc351e0e66250b21cf9aef3ccaa3e0c28b085fd095e947d92a5336b9e65706d649a7b79541feab3487f" },
|
||||
{ "verifyFp", "verify_migsr5je_BJ1YiVbY_uR2U_4VXu_8Uje_GgwhVf7Y6hb8" },
|
||||
{ "fp", "verify_migsr5je_BJ1YiVbY_uR2U_4VXu_8Uje_GgwhVf7Y6hb8" },
|
||||
//{ "msToken", "zVKqVOb-uBF-Opse1B5y9u0hw7SlRa49w8HB56cVSY0Uymjqs17JJ9qOUXv7IK0j3de1ErBjSVAm0JSd5SH1sYg7jeriVvGNJoDUM3dKwVCrqlapZgU9g2aYAeHs5oHddTGtZ3Pri9MIRltXVRdirZWMewfH8MlqmIVaCiTuGzRcZ6va9aWe0CyR" },
|
||||
//{ "a_bogus", "dy45kFWjOxRfOdFtmOnc9WxlY8L%2FNTuy6Pi2SYAP9PKGcwFcaWNpBNCfrxLuRUd%2FzuBzhe3HqdlMYDnc0zX0ZenkKmkkupv6Bt%2FC9L0LZZHvbBJZ7rgiemSxzk4O8KsOmAIbiM75AsBEIxo5VrCwAdlCu%2F-xBbmD%2Fp3vVATCE2ysUAujwn%2FVa-JDNw7qaf%3D%3D" },
|
||||
//{ "x-secsdk-web-expire", "1764378577130" },
|
||||
//{ "x-secsdk-web-signature", "3b8aa32e9457ebe3b65afdae11e2fe86" }
|
||||
};
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 字典扩展方法:批量添加键值对(避免重复 Add 调用)
|
||||
/// </summary>
|
||||
private static void AddRange(this Dictionary<string, string> target, Dictionary<string, string> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
// 避免键重复(如果有重复以源字典为准)
|
||||
if (target.ContainsKey(item.Key))
|
||||
target[item.Key] = item.Value;
|
||||
else
|
||||
target.Add(item.Key, item.Value);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace dy.net.utils
|
||||
/// <summary>
|
||||
/// 抖音标题转文件名工具类(兼容Windows/macOS/Linux)
|
||||
/// </summary>
|
||||
public static class TikTokFileNameHelper
|
||||
public static class DouyinFileNameHelper
|
||||
{
|
||||
#region 配置参数
|
||||
/// <summary>
|
||||
@@ -153,9 +153,9 @@ namespace dy.net.utils
|
||||
{
|
||||
path = path.Replace(c, '_');
|
||||
}
|
||||
if (path.Length > 50)
|
||||
if (path.Length > 100)
|
||||
{
|
||||
path = path.Substring(0, 50);
|
||||
path = path.Substring(0, 100);
|
||||
}
|
||||
return path.Trim().Replace(" ", "");
|
||||
}
|
||||
@@ -164,5 +164,31 @@ namespace dy.net.utils
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 检查字符串是否仅包含字母、数字、简体中文(无特殊字符)
|
||||
/// </summary>
|
||||
/// <param name="input">待检查的字符串</param>
|
||||
/// <returns>true:无特殊字符(仅允许字符);false:含有特殊字符</returns>
|
||||
public static bool IsValidWithoutSpecialChars(string input)
|
||||
{
|
||||
// 空字符串默认返回 true(若需禁止空字符串,可先判断 string.IsNullOrWhiteSpace(input) 并返回 false)
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return true;
|
||||
|
||||
// 正则表达式说明:
|
||||
// ^ :匹配字符串开头
|
||||
// $ :匹配字符串结尾
|
||||
// [a-zA-Z0-9\u4E00-\u9FA5] :允许的字符范围
|
||||
// a-zA-Z:大小写字母
|
||||
// 0-9:数字
|
||||
// \u4E00-\u9FA5:简体中文 Unicode 核心范围(覆盖99%+简体中文常用字)
|
||||
// * :匹配 0 个或多个允许的字符(若需至少1个字符,可改为 +)
|
||||
const string pattern = @"^[a-zA-Z0-9\u4E00-\u9FA5]*$";
|
||||
|
||||
// 忽略文化差异,仅按字符编码匹配
|
||||
return Regex.IsMatch(input, pattern, RegexOptions.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.net.utils
|
||||
{
|
||||
public class FFmpegHelper : IDisposable
|
||||
{
|
||||
#if DEBUG
|
||||
// Debug 环境,通常是 Windows
|
||||
private readonly string _ffmpegExecutablePath = "E:\\down\\ffmpeg\\bin\\ffmpeg.exe";
|
||||
private readonly string _ffprobeExecutablePath = "E:\\down\\ffmpeg\\bin\\ffprobe.exe";
|
||||
#else
|
||||
// Release 环境,通常是 Docker Linux
|
||||
private readonly string _ffmpegExecutablePath = "ffmpeg";
|
||||
private readonly string _ffprobeExecutablePath = "ffprobe";
|
||||
#endif
|
||||
|
||||
|
||||
private Process _ffmpegProcess;
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
|
||||
// 视频参数
|
||||
public int VideoWidth { get; set; } = 1080;
|
||||
public int VideoHeight { get; set; } = 1920;
|
||||
public int OutputFrameRate { get; set; } = 30;
|
||||
public int ImageDisplayDurationSeconds { get; set; } = 2;
|
||||
|
||||
// 编码参数
|
||||
public string VideoCodec { get; set; } = "libx264";
|
||||
public string VideoPreset { get; set; } = "medium";
|
||||
public int VideoCrf { get; set; } = 23;
|
||||
public string AudioCodec { get; set; } = "aac";
|
||||
public string AudioBitrate { get; set; } = "192k";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 将多张图片和一个音频文件合成为视频(最终终极版)。
|
||||
/// </summary>
|
||||
public async Task<string> CreateVideoFromImagesAndAudioAsync(
|
||||
IEnumerable<string> imageFilePaths,
|
||||
string audioFilePath,
|
||||
string outputVideoPath,
|
||||
int VideoWidth = 1080,
|
||||
int Height = 1920,
|
||||
IProgress<double> progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 输入验证
|
||||
if (imageFilePaths == null || !imageFilePaths.Any())
|
||||
throw new ArgumentException("图片路径列表不能为空。", nameof(imageFilePaths));
|
||||
|
||||
if (string.IsNullOrEmpty(audioFilePath) || !File.Exists(audioFilePath))
|
||||
throw new FileNotFoundException("音频文件未找到。", audioFilePath);
|
||||
|
||||
if (string.IsNullOrEmpty(outputVideoPath))
|
||||
throw new ArgumentNullException(nameof(outputVideoPath));
|
||||
|
||||
foreach (var imagePath in imageFilePaths)
|
||||
{
|
||||
if (!File.Exists(imagePath))
|
||||
throw new FileNotFoundException("图片文件未找到。", imagePath);
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(outputVideoPath);
|
||||
if (!string.IsNullOrEmpty(outputDirectory) && !Directory.Exists(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
// 关键步骤 1: 创建临时目录并生成有序图片序列
|
||||
string tempImageDir = Path.Combine(AppContext.BaseDirectory, "temp", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempImageDir);
|
||||
|
||||
var imageList = imageFilePaths.ToList();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < imageList.Count; i++)
|
||||
{
|
||||
string sourcePath = imageList[i];
|
||||
string extension = Path.GetExtension(sourcePath);
|
||||
string destFileName = $"temp_{i + 1:D3}{extension}";
|
||||
string destPath = Path.Combine(tempImageDir, destFileName);
|
||||
File.Copy(sourcePath, destPath);
|
||||
}
|
||||
|
||||
string imageSequencePattern = Path.Combine(tempImageDir, "temp_%03d" + Path.GetExtension(imageList[0]));
|
||||
double imageFps = Math.Round(1.0 / ImageDisplayDurationSeconds, 2);
|
||||
|
||||
// --- 修正点 1: 整合音频时长获取逻辑 ---
|
||||
// 我们不再需要 GetAudioFilterAsync,而是直接获取时长用于计算视频循环
|
||||
double audioDurationSeconds = imageList.Count * ImageDisplayDurationSeconds; // 默认值为图片总时长
|
||||
try
|
||||
{
|
||||
// 使用 ffprobe 获取音频时长
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _ffprobeExecutablePath,
|
||||
Arguments = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{audioFilePath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using (var process = new Process { StartInfo = startInfo })
|
||||
{
|
||||
process.Start();
|
||||
string output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (double.TryParse(output, out double duration))
|
||||
{
|
||||
audioDurationSeconds = duration;
|
||||
//Console.WriteLine($"成功获取音频时长: {audioDurationSeconds:F2}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error($"获取音频时长失败,将使用图片总时长 ({audioDurationSeconds:F2}s) 作为视频时长: {ex.Message}");
|
||||
}
|
||||
|
||||
// 计算图片序列需要循环的次数
|
||||
int loopCount = 1;
|
||||
double singleLoopDuration = imageList.Count * ImageDisplayDurationSeconds;
|
||||
if (audioDurationSeconds > singleLoopDuration)
|
||||
{
|
||||
loopCount = (int)Math.Ceiling(audioDurationSeconds / singleLoopDuration);
|
||||
//Console.WriteLine($"图片序列将循环 {loopCount} 次以匹配音频时长");
|
||||
}
|
||||
|
||||
// --- 修正点 2: 重新构建 FFmpeg 参数列表 ---
|
||||
//var arguments = new List<string>
|
||||
//{
|
||||
// "-y", // 覆盖输出文件
|
||||
|
||||
// // --- 所有输入文件放在最前面 ---
|
||||
// // 图片序列输入
|
||||
// "-f", "image2",
|
||||
// "-r", imageFps.ToString(CultureInfo.InvariantCulture),
|
||||
// "-i", $"\"{imageSequencePattern}\"",
|
||||
|
||||
// // 音频输入
|
||||
// "-i", $"\"{audioFilePath}\"",
|
||||
|
||||
// // --- 修正点 3: 使用 filter_complex 对视频流进行循环 ---
|
||||
// // [0:v] 表示第一个输入(图片序列)的视频流
|
||||
// // loop={loopCount-1} 表示循环 (次数-1) 次
|
||||
// // [v] 是处理后的视频流的别名
|
||||
// "-filter_complex", $"\"[0:v]loop={loopCount - 1}[v]\"",
|
||||
|
||||
// // --- 修正点 4: 明确映射输出流 ---
|
||||
// // 将处理后的视频流 [v] 映射到输出
|
||||
// "-map", "\"[v]\"",
|
||||
// // 将第二个输入(音频文件)的音频流映射到输出
|
||||
// "-map", "\"1:a\"",
|
||||
|
||||
// // --- 视频编码配置 ---
|
||||
// "-c:v", VideoCodec,
|
||||
// "-preset", VideoPreset,
|
||||
// "-crf", $"{VideoCrf}",
|
||||
// "-s", $"{VideoWidth}x{VideoHeight}",
|
||||
// "-pix_fmt", "yuv420p",
|
||||
|
||||
// // --- 音频编码配置 ---
|
||||
// "-c:a", AudioCodec,
|
||||
// "-b:a", $"{AudioBitrate}",
|
||||
|
||||
// // --- 修正点 5: 使用 -shortest 参数 ---
|
||||
// // 确保视频和音频同时结束,即使循环次数计算得不完全精确
|
||||
// "-shortest",
|
||||
|
||||
// // --- 输出文件 ---
|
||||
// $"\"{outputVideoPath}\""
|
||||
//};
|
||||
|
||||
//AI优化后。。20251129
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-y", // 覆盖输出文件
|
||||
|
||||
// 图片序列输入:恢复你原有的 -r,移除可能冲突的参数
|
||||
"-f", "image2",
|
||||
"-r", imageFps.ToString(CultureInfo.InvariantCulture), // 保留你原本的帧率参数
|
||||
"-start_number", "0", // 仅增加:避免序列编号问题(不影响原有逻辑)
|
||||
"-i", imageSequencePattern, // 关键修复:去掉引号(原代码的引号是核心失败原因)
|
||||
|
||||
// 音频输入:同样去掉引号
|
||||
"-i", audioFilePath,
|
||||
|
||||
// 滤镜修复:恢复简单写法,避免复杂参数(适配旧版 FFmpeg)
|
||||
"-filter_complex", $"[0:v]loop={loopCount - 1}[v]", // 去掉额外参数,和你原逻辑一致
|
||||
|
||||
// 流映射:去掉引号,恢复简单写法
|
||||
"-map", "[v]",
|
||||
"-map", "1:a",
|
||||
|
||||
// 视频编码:保留你的变量,只增加兼容性参数
|
||||
"-c:v", VideoCodec, // 保留你原本的编码变量(之前能跑通,说明该编码支持)
|
||||
"-preset", VideoPreset, // 保留原变量
|
||||
"-crf", $"{VideoCrf}", // 保留原变量
|
||||
"-s", $"{VideoWidth}x{VideoHeight}",
|
||||
"-pix_fmt", "yuv420p", // 仅增加:兼容性关键(不影响原有逻辑)
|
||||
"-profile:v", "main", // 仅增加:适配多数播放器(不冲突)
|
||||
|
||||
// 音频编码:保留你的变量,修复可能的无效值
|
||||
"-c:a", AudioCodec, // 保留原变量
|
||||
"-b:a", $"{AudioBitrate}", // 保留原变量
|
||||
"-ac", "2", // 仅增加:避免单声道兼容问题
|
||||
"-ar", "44100", // 仅增加:标准化采样率
|
||||
|
||||
// 封装优化:仅增加关键参数,不影响原有逻辑
|
||||
"-f", "mp4", // 明确格式(之前可能自动识别,现在显式声明更稳定)
|
||||
"-movflags", "+faststart", // 仅增加:解决部分播放器无法播放
|
||||
|
||||
// 保留你原有的同步参数
|
||||
"-shortest",
|
||||
|
||||
// 输出路径:去掉引号(核心修复点)
|
||||
outputVideoPath
|
||||
};
|
||||
|
||||
string args = string.Join(" ", arguments);
|
||||
//Console.WriteLine($"执行FFmpeg命令: {_ffmpegExecutablePath} {args}");
|
||||
|
||||
// 执行命令
|
||||
await ExecuteFFmpegAsync(args, progress, cancellationToken);
|
||||
if (File.Exists(outputVideoPath))
|
||||
{
|
||||
return outputVideoPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("视频合成失败,未生成输出文件。");
|
||||
}
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理临时目录
|
||||
if (Directory.Exists(tempImageDir))
|
||||
{
|
||||
Directory.Delete(tempImageDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 异步执行FFmpeg命令
|
||||
/// </summary>
|
||||
private async Task ExecuteFFmpegAsync(string arguments, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ffmpegProcess != null && !_ffmpegProcess.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException("已有一个FFmpeg进程正在运行。");
|
||||
}
|
||||
|
||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = _ffmpegExecutablePath,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = System.Text.Encoding.UTF8,
|
||||
StandardErrorEncoding = System.Text.Encoding.UTF8
|
||||
};
|
||||
|
||||
_ffmpegProcess = new Process { StartInfo = startInfo };
|
||||
|
||||
_ffmpegProcess.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Data)) return;
|
||||
//Console.WriteLine($"FFmpeg: {e.Data}");
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_ffmpegProcess.Start();
|
||||
_ffmpegProcess.BeginErrorReadLine();
|
||||
|
||||
using (_cancellationTokenSource.Token.Register(() =>
|
||||
{
|
||||
if (_ffmpegProcess != null && !_ffmpegProcess.HasExited)
|
||||
{
|
||||
try { _ffmpegProcess.Kill(); } catch { }
|
||||
}
|
||||
}))
|
||||
{
|
||||
await _ffmpegProcess.WaitForExitAsync(_cancellationTokenSource.Token);
|
||||
}
|
||||
|
||||
if (_cancellationTokenSource.Token.IsCancellationRequested)
|
||||
{
|
||||
throw new OperationCanceledException("FFmpeg进程被用户取消。", _cancellationTokenSource.Token);
|
||||
}
|
||||
|
||||
if (_ffmpegProcess.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"FFmpeg执行失败,退出码: {_ffmpegProcess.ExitCode}。请查看控制台输出获取详细错误信息。");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ffmpegProcess?.Dispose();
|
||||
_ffmpegProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_ffmpegProcess?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,5 +5,12 @@
|
||||
public static string DOWN_IMAGE_VIDEO_ENABLE="DOWN_IMGVIDEO";
|
||||
|
||||
public static string ASPNETCORE_URLS = "ASPNETCORE_URLS";
|
||||
|
||||
|
||||
public static string DY_FOLLOWEDS = "dy_followeds";
|
||||
public static string DY_COLLECTS = "dy_collects";
|
||||
public static string DY_FAVORITES = "dy_favorites";
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using dy.net.dto;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace dy.net.utils
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频标题模板生成器
|
||||
/// </summary>
|
||||
public static class VideoTitleGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据用户模板和原始数据生成最终标题
|
||||
/// </summary>
|
||||
/// <param name="template">用户设置的模板(如 "视频_{id}_{VideoTitle}_{ReleaseTime}")</param>
|
||||
/// <param name="data">标题所需的原始数据</param>
|
||||
/// <param name="timeFormat">时间字段格式化(默认:yyyy-MM-dd HH:mm:ss)</param>
|
||||
/// <param name="emptyPlaceholder">占位符对应数据为空时的替换值(默认:空字符串)</param>
|
||||
/// <returns>生成的最终标题</returns>
|
||||
/// <exception cref="ArgumentNullException">模板为空时抛出</exception>
|
||||
public static string Generate(
|
||||
string template,
|
||||
VideoTitleDataTemplate data,
|
||||
string timeFormat = "yyyyMMddHHmmss",
|
||||
string emptyPlaceholder = "")
|
||||
{
|
||||
// 校验入参
|
||||
if (string.IsNullOrWhiteSpace(template))
|
||||
throw new ArgumentNullException(nameof(template), "标题模板不能为空");
|
||||
data ??= new VideoTitleDataTemplate(); // 避免数据为空
|
||||
|
||||
// 1. 定义占位符与数据的映射关系(key:占位符名称,value:格式化后的值)
|
||||
var placeholderMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// 普通字段
|
||||
["Id"] = data.Id.ToString(),
|
||||
["VideoTitle"] = string.IsNullOrWhiteSpace(data.VideoTitle) ? emptyPlaceholder : data.VideoTitle,
|
||||
["FileHash"] = string.IsNullOrWhiteSpace(data.FileHash) ? emptyPlaceholder : data.FileHash,
|
||||
["Resolution"] = string.IsNullOrWhiteSpace(data.Resolution) ? emptyPlaceholder : data.Resolution,
|
||||
|
||||
// 时间字段(支持空值处理)
|
||||
//["SyncTime"] = data.SyncTime.HasValue ? data.SyncTime.Value.ToString(timeFormat) : emptyPlaceholder,
|
||||
["ReleaseTime"] = data.ReleaseTime.HasValue ? data.ReleaseTime.Value.ToString(timeFormat) : emptyPlaceholder,
|
||||
|
||||
// 文件大小(自动格式化:字节→KB/MB/GB,保留1位小数)
|
||||
//["FileSize"] = FormatFileSize(data.FileSize) ?? emptyPlaceholder
|
||||
};
|
||||
|
||||
// 2. 正则匹配模板中的占位符({占位符名称}),并替换
|
||||
var regex = new Regex(@"\{(?<key>[a-zA-Z0-9]+)\}", RegexOptions.Compiled);
|
||||
var finalTitle = regex.Replace(template, match =>
|
||||
{
|
||||
var placeholderKey = match.Groups["key"].Value;
|
||||
// 存在对应映射则替换,否则保留原占位符(避免替换错误)
|
||||
return placeholderMap.TryGetValue(placeholderKey, out var value) ? value : match.Value;
|
||||
});
|
||||
|
||||
return finalTitle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化文件大小(字节→KB/MB/GB)
|
||||
/// </summary>
|
||||
private static string FormatFileSize(long fileSizeInBytes)
|
||||
{
|
||||
if (fileSizeInBytes < 0) return "无效大小";
|
||||
if (fileSizeInBytes == 0) return "0B";
|
||||
|
||||
const long kb = 1024;
|
||||
const long mb = kb * 1024;
|
||||
const long gb = mb * 1024;
|
||||
|
||||
return fileSizeInBytes switch
|
||||
{
|
||||
< kb => $"{fileSizeInBytes}B",
|
||||
< mb => $"{fileSizeInBytes / (double)kb:F1}KB",
|
||||
< gb => $"{fileSizeInBytes / (double)mb:F1}MB",
|
||||
_ => $"{fileSizeInBytes / (double)gb:F1}GB"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace dy.net.utils
|
||||
{
|
||||
public class XBogus
|
||||
{
|
||||
private readonly int?[] _array;
|
||||
private readonly string _character;
|
||||
private readonly byte[] _uaKey = { 0x00, 0x01, 0x0c };
|
||||
private readonly string _userAgent;
|
||||
|
||||
public string Params { get; private set; }
|
||||
public string Xb { get; private set; }
|
||||
|
||||
public XBogus(string userAgent = "")
|
||||
{
|
||||
// 初始化 Array 数组(对应 Python 的 self.Array)
|
||||
_array = new int?[128];
|
||||
// 数字 0-9 对应 ASCII 48-57
|
||||
for (int i = 48; i <= 57; i++)
|
||||
_array[i] = i - 48;
|
||||
// 字母 A-F 对应 ASCII 65-70,映射为 10-15
|
||||
for (int i = 65; i <= 70; i++)
|
||||
_array[i] = i - 55;
|
||||
// 字母 a-f 对应 ASCII 97-102,映射为 10-15
|
||||
for (int i = 97; i <= 102; i++)
|
||||
_array[i] = i - 87;
|
||||
|
||||
// 字符映射表
|
||||
_character = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=";
|
||||
|
||||
// 用户代理,默认值与 Python 一致
|
||||
_userAgent = string.IsNullOrEmpty(userAgent)
|
||||
? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0"
|
||||
: userAgent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将字符串通过 MD5 哈希转换为整数数组
|
||||
/// </summary>
|
||||
private int[] Md5StrToArray(string md5Str)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(md5Str) && md5Str.Length > 32)
|
||||
return md5Str.Select(c => (int)c).ToArray();
|
||||
|
||||
var result = new List<int>();
|
||||
for (int i = 0; i < md5Str.Length; i += 2)
|
||||
{
|
||||
if (i + 1 >= md5Str.Length)
|
||||
break;
|
||||
|
||||
int? high = _array[md5Str[i]];
|
||||
int? low = _array[md5Str[i + 1]];
|
||||
if (high == null || low == null)
|
||||
result.Add(0);
|
||||
else
|
||||
result.Add(((int)high << 4) | (int)low);
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 多轮 MD5 哈希加密 URL 参数
|
||||
/// </summary>
|
||||
private int[] Md5Encrypt(string urlParams)
|
||||
{
|
||||
string firstMd5 = Md5(urlParams);
|
||||
int[] firstArray = Md5StrToArray(firstMd5);
|
||||
string secondMd5 = Md5(firstArray);
|
||||
return Md5StrToArray(secondMd5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算 MD5 哈希值
|
||||
/// </summary>
|
||||
private string Md5(object input)
|
||||
{
|
||||
int[] dataArray;
|
||||
switch (input)
|
||||
{
|
||||
case string str:
|
||||
dataArray = Md5StrToArray(str);
|
||||
break;
|
||||
case int[] arr:
|
||||
dataArray = arr;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Invalid input type. Expected string or int array.");
|
||||
}
|
||||
|
||||
using (var md5 = MD5.Create())
|
||||
{
|
||||
byte[] bytes = dataArray.Select(i => (byte)(i & 0xFF)).ToArray();
|
||||
byte[] hashBytes = md5.ComputeHash(bytes);
|
||||
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第一次编码转换
|
||||
/// </summary>
|
||||
private string EncodingConversion(
|
||||
int a, int b, int c, int e, int d, int t, int f, int r, int n, int o,
|
||||
int i, int _, int x, int u, int s, int l, int v, int h, int p)
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
(byte)a, (byte)i, (byte)b, (byte)_ , (byte)c, (byte)x,
|
||||
(byte)e, (byte)u, (byte)d, (byte)s, (byte)t, (byte)l,
|
||||
(byte)f, (byte)v, (byte)r, (byte)h, (byte)n, (byte)p, (byte)o
|
||||
};
|
||||
return Encoding.GetEncoding("ISO-8859-1").GetString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 第二次编码转换
|
||||
/// </summary>
|
||||
private string EncodingConversion2(int a, int b, string c)
|
||||
{
|
||||
return ((char)a).ToString() + ((char)b).ToString() + c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RC4 加密算法
|
||||
/// </summary>
|
||||
private byte[] Rc4Encrypt(byte[] key, byte[] data)
|
||||
{
|
||||
int[] S = Enumerable.Range(0, 256).ToArray();
|
||||
int j = 0;
|
||||
|
||||
// 初始化 S 盒
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
j = (j + S[i] + key[i % key.Length]) % 256;
|
||||
(S[i], S[j]) = (S[j], S[i]);
|
||||
}
|
||||
|
||||
// 生成密文
|
||||
var encrypted = new byte[data.Length];
|
||||
int i2 = 0, j2 = 0;
|
||||
for (int k = 0; k < data.Length; k++)
|
||||
{
|
||||
i2 = (i2 + 1) % 256;
|
||||
j2 = (j2 + S[i2]) % 256;
|
||||
(S[i2], S[j2]) = (S[j2], S[i2]);
|
||||
int t = (S[i2] + S[j2]) % 256;
|
||||
encrypted[k] = (byte)(data[k] ^ S[t]);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 位运算计算
|
||||
/// </summary>
|
||||
private string Calculation(int a1, int a2, int a3)
|
||||
{
|
||||
int x1 = (a1 & 0xFF) << 16;
|
||||
int x2 = (a2 & 0xFF) << 8;
|
||||
int x3 = x1 | x2 | (a3 & 0xFF);
|
||||
|
||||
char c1 = _character[(x3 & 0x0FC0000) >> 18]; // 16515072 = 0x0FC0000
|
||||
char c2 = _character[(x3 & 0x003F000) >> 12]; // 258048 = 0x003F000
|
||||
char c3 = _character[(x3 & 0x0000FC0) >> 6]; // 4032 = 0x0000FC0
|
||||
char c4 = _character[x3 & 0x3F];
|
||||
|
||||
return $"{c1}{c2}{c3}{c4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取 X-Bogus 值
|
||||
/// </summary>
|
||||
public (string Params, string Xb, string UserAgent) GetXBogus(string urlParams)
|
||||
{
|
||||
// 计算 array1
|
||||
byte[] uaBytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(_userAgent);
|
||||
byte[] rc4Ua = Rc4Encrypt(_uaKey, uaBytes);
|
||||
string base64Ua = Convert.ToBase64String(rc4Ua);
|
||||
string md5Ua = Md5(base64Ua);
|
||||
int[] array1 = Md5StrToArray(md5Ua);
|
||||
|
||||
// 计算 array2(固定 MD5:d41d8cd98f00b204e9800998ecf8427e 是空字符串的 MD5)
|
||||
int[] array2 = Md5StrToArray(Md5(Md5StrToArray("d41d8cd98f00b204e9800998ecf8427e")));
|
||||
|
||||
// 计算 URL 参数的 MD5 数组
|
||||
int[] urlParamsArray = Md5Encrypt(urlParams);
|
||||
|
||||
// 时间戳和固定值
|
||||
long timer = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
int ct = 536919696;
|
||||
|
||||
// 构建 new_array
|
||||
var newArray = new List<double>
|
||||
{
|
||||
64, 0.00390625, 1, 12,
|
||||
urlParamsArray.Length > 14 ? urlParamsArray[14] : 0,
|
||||
urlParamsArray.Length > 15 ? urlParamsArray[15] : 0,
|
||||
array2.Length > 14 ? array2[14] : 0,
|
||||
array2.Length > 15 ? array2[15] : 0,
|
||||
array1.Length > 14 ? array1[14] : 0,
|
||||
array1.Length > 15 ? array1[15] : 0,
|
||||
(timer >> 24) & 0xFF,
|
||||
(timer >> 16) & 0xFF,
|
||||
(timer >> 8) & 0xFF,
|
||||
timer & 0xFF,
|
||||
(ct >> 24) & 0xFF,
|
||||
(ct >> 16) & 0xFF,
|
||||
(ct >> 8) & 0xFF,
|
||||
ct & 0xFF
|
||||
};
|
||||
|
||||
// 计算异或结果
|
||||
int xorResult = (int)newArray[0];
|
||||
for (int i = 1; i < newArray.Count; i++)
|
||||
{
|
||||
int b = (int)newArray[i];
|
||||
xorResult ^= b;
|
||||
}
|
||||
newArray.Add(xorResult);
|
||||
|
||||
// 拆分 array3 和 array4
|
||||
var array3 = new List<int>();
|
||||
var array4 = new List<int>();
|
||||
for (int i = 0; i < newArray.Count; i++)
|
||||
{
|
||||
array3.Add((int)newArray[i]);
|
||||
if (i + 1 < newArray.Count)
|
||||
array4.Add((int)newArray[i + 1]);
|
||||
i++;
|
||||
}
|
||||
|
||||
// 合并数组
|
||||
int[] mergeArray = array3.Concat(array4).ToArray();
|
||||
|
||||
// 生成乱码
|
||||
string encoding1 = EncodingConversion(
|
||||
mergeArray[0], mergeArray[1], mergeArray[2], mergeArray[3], mergeArray[4],
|
||||
mergeArray[5], mergeArray[6], mergeArray[7], mergeArray[8], mergeArray[9],
|
||||
mergeArray[10], mergeArray[11], mergeArray[12], mergeArray[13], mergeArray[14],
|
||||
mergeArray[15], mergeArray[16], mergeArray[17], mergeArray[18]
|
||||
);
|
||||
|
||||
byte[] encoding1Bytes = Encoding.GetEncoding("ISO-8859-1").GetBytes(encoding1);
|
||||
byte[] rc4Key = Encoding.GetEncoding("ISO-8859-1").GetBytes("ÿ");
|
||||
byte[] rc4Encrypted = Rc4Encrypt(rc4Key, encoding1Bytes);
|
||||
string rc4Str = Encoding.GetEncoding("ISO-8859-1").GetString(rc4Encrypted);
|
||||
|
||||
string garbledCode = EncodingConversion2(2, 255, rc4Str);
|
||||
|
||||
// 计算 X-Bogus
|
||||
StringBuilder xbBuilder = new StringBuilder();
|
||||
for (int i = 0; i < garbledCode.Length; i += 3)
|
||||
{
|
||||
if (i + 2 >= garbledCode.Length)
|
||||
break;
|
||||
|
||||
int a = garbledCode[i];
|
||||
int b = garbledCode[i + 1];
|
||||
int c = garbledCode[i + 2];
|
||||
xbBuilder.Append(Calculation(a, b, c));
|
||||
}
|
||||
|
||||
// 结果赋值
|
||||
Xb = xbBuilder.ToString();
|
||||
Params = $"{urlParams}&X-Bogus={Xb}";
|
||||
|
||||
return (Params, Xb, _userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试代码
|
||||
//public class XBogusTest
|
||||
//{
|
||||
// public static void Main()
|
||||
// {
|
||||
// string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
|
||||
// var xb = new XBogus(ua);
|
||||
|
||||
// string dyUrlParams = "device_platform=webapp&aid=6383&channel=channel_pc_web&sec_user_id=MS4wLjABAAAAW9FWcqS7RdQAWPd2AA5fL_ilmqsIFUCQ_Iym6Yh9_cUa6ZRqVLjVQSUjlHrfXY1Y&max_cursor=0&locate_query=false&show_live_replay_strategy=1&need_time_list=1&time_list_query=0&whale_cut_token=&cut_version=1&count=18&publish_video_strategy_type=2&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=Win32&browser_name=Edge&browser_version=122.0.0.0&browser_online=true&engine_name=Blink&engine_version=122.0.0.0&os_name=Windows&os_version=10&cpu_core_num=12&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7335414539335222835&msToken=p9Y7fUBuq9DKvAuN27Peml6JbaMqG2ZcXfFiyDv1jcHrCN00uidYqUgSuLsKl1onC-E_n82m-aKKYE0QGEmxIWZx9iueQ6WLbvzPfqnMk4GBAlQIHcDzxb38FLXXQxAm";
|
||||
// string tkUrlParams = "WebIdLastTime=1713796127&abTestVersion=%5Bobject%20Object%5D&aid=1988&appType=t&app_language=zh-Hans&app_name=tiktok_web&browser_name=Mozilla&browser_online=true&browser_platform=Win32&browser_version=5.0%20%28Windows%20NT%2010.0%3B%20Win64%3B%20x64%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F123.0.0.0%20Safari%2F537.36&channel=tiktok_web&device_id=7360698239018452498&odinId=7360698115047851026®ion=TW&tz_name=Asia%2FHong_Kong&uniqueId=rei_toy625";
|
||||
|
||||
// var dyResult = xb.GetXBogus(dyUrlParams);
|
||||
// Console.WriteLine($"Douyin - URL: {dyResult.Params}, X-Bogus: {dyResult.Xb}, UA: {dyResult.UserAgent}");
|
||||
|
||||
// var tkResult = xb.GetXBogus(tkUrlParams);
|
||||
// Console.WriteLine($"TikTok - URL: {tkResult.Params}, X-Bogus: {tkResult.Xb}, UA: {tkResult.UserAgent}");
|
||||
// }
|
||||
//}
|
||||
}
|
||||