1、去重

2、分享
3、视频播放
4、批量删除,重新下载
5、自定义标题(仅博主视频)
6、授权页面去掉博主添加,新增关注列表
7、图文视频合成优化
8、其他优化
This commit is contained in:
jianzhichu
2025-11-30 00:42:25 +08:00
parent 63b51d211e
commit e6e9ff3cc3
94 changed files with 8155 additions and 1544 deletions
+22 -25
View File
@@ -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());
//避免前端等待
}
}
+149
View File
@@ -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
});
}
}
}
File diff suppressed because one or more lines are too long
+105 -37
View File
@@ -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 });
}
}
}
}
}
View File
+22 -18
View File
@@ -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>
+1 -1
View File
@@ -2,7 +2,7 @@
"profiles": {
"dy.net": {
"commandName": "Project",
"launchBrowser": true,
//"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
+2 -2
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

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>
@@ -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>
Binary file not shown.

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>
+1 -1
View File
@@ -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 -2
View File
@@ -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';
+893
View File
@@ -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>
+205 -83
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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: '系统配置',
+53 -8
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"dbconn": "",
"dbtype": "Sqlite",
"DOWN_IMGVIDEO": ""
"DOWN_IMGVIDEO": "1"
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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; }
}
+28 -3
View File
@@ -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; }
}
}
+36
View File
@@ -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; }
}
}
+31
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace dy.net.dto
{
public class ReDownViedoDto
{
public List<string> Ids { get; set; }
}
}
+26
View File
@@ -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;
}
}
+11
View File
@@ -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
}
}
+1 -5
View File
@@ -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>
-9
View File
@@ -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
+2 -19
View File
@@ -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);
}
}
}
+2 -2
View File
@@ -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
+8 -20
View File
@@ -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>
+28 -35
View File
@@ -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;
}
}
}
+26 -31
View File
@@ -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;
}
}
}
-149
View File
@@ -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);
+81
View File
@@ -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}人。");
}
}
}
}
}
+203
View File
@@ -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
};
}
}
}
+25 -2
View File
@@ -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; }
}
}
+142
View File
@@ -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;
// }
// }
//}
}
}
+71
View File
@@ -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; }
}
}
-142
View File
@@ -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;
}
}
}
}
}
+37
View File
@@ -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; }
}
}
+15
View File
@@ -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>
/// 根据主键删除(异步)
+40
View File
@@ -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);
}
}
}
+252
View File
@@ -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;
}
}
}
-29
View File
@@ -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);
}
}
}
+127 -35
View File
@@ -15,50 +15,48 @@ 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)
/// <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? start = null;
DateTime? end =null;
if(dates!=null && dates.Count==2)
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();
}
}
}
+30 -24
View File
@@ -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>
+9 -9
View File
@@ -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);
}
+89
View File
@@ -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);
}
}
}
+84 -211
View File
@@ -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;
// }
//}
}
}
+421
View File
@@ -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);
}
}
}
+248 -146
View File
@@ -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",
// "抖音重新下载任务(单次执行)")
//}
};
}
}
}
+173 -11
View File
@@ -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();
}
}
}
+1 -1
View File
@@ -205,7 +205,7 @@ namespace dy.net.utils
return Sm3ToArray(Sm3ToArray(method + _endString));
}
// ... 其他成员和初始化代码 ...
public int[] Sm3ToArray(string data)
{
+535
View File
@@ -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}");
// //}
//}
}
+268 -154
View File
@@ -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" },
// 用户标识参数
{ "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" },
{ "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" }
};
var parameters = GetBaseParameters();
// 覆盖基础参数
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";
// 添加特有参数
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);
}
}
}
+328
View File
@@ -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();
}
}
}
+7
View File
@@ -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";
}
}
+81
View File
@@ -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"
};
}
}
}
+290
View File
@@ -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&region=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}");
// }
//}
}