feat: 将本地独有功能移植到 upstream 最新版本
基于 upstream/master (c5922f9) 新建分支,移植以下功能:
1. 扫码登录(DyQrCodeService + ConfigController 3个接口)
2. 视频预览组件(VideoPlayer.vue)
3. 下载配置服务(DownloadConfigService + AppConfig 6个扩展字段)
4. Gitea CI/CD(.gitea/workflows/deploy.yml)
5. 生产 Dockerfile(Dockerfile.prod,含中国镜像源配置)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
name: 部署到群晖
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 安装 Docker CLI
|
||||||
|
run: |
|
||||||
|
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时
|
||||||
|
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
|
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io
|
||||||
|
|
||||||
|
- name: 拉取代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 构建镜像
|
||||||
|
run: docker build -f Dockerfile.prod -t dysync:latest .
|
||||||
|
|
||||||
|
- name: 停止旧容器
|
||||||
|
run: |
|
||||||
|
docker stop dysync 2>/dev/null || true
|
||||||
|
docker rm dysync 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: 启动新容器
|
||||||
|
run: |
|
||||||
|
docker run -d \
|
||||||
|
--name dysync \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 10101:10101 \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
-v /volume1/docker/apps/dysync/db:/app/db \
|
||||||
|
-v /volume1/docker/apps/dysync/logs:/app/logs \
|
||||||
|
-v /volume2/save/dysync/collect:/app/collect \
|
||||||
|
-v /volume2/save/dysync/favorite:/app/favorite \
|
||||||
|
-v /volume2/save/dysync/uper:/app/uper \
|
||||||
|
dysync:latest
|
||||||
|
|
||||||
|
- name: 部署完成提示
|
||||||
|
run: |
|
||||||
|
echo "✅ 部署完成,浏览器访问 http://192.168.1.66:10101 查看"
|
||||||
@@ -25,10 +25,11 @@ namespace dy.net.Controllers
|
|||||||
private readonly DouyinFollowService douyinFollowService;
|
private readonly DouyinFollowService douyinFollowService;
|
||||||
private readonly DouyinCookieService douyinCookieService;
|
private readonly DouyinCookieService douyinCookieService;
|
||||||
private readonly DouyinHttpClientService httpClientService;
|
private readonly DouyinHttpClientService httpClientService;
|
||||||
|
private readonly DyQrCodeService _qrCodeService;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService)
|
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService, DyQrCodeService qrCodeService)
|
||||||
{
|
{
|
||||||
this.dyCookieService = dyCookieService;
|
this.dyCookieService = dyCookieService;
|
||||||
this.commonService = commonService;
|
this.commonService = commonService;
|
||||||
@@ -36,6 +37,7 @@ namespace dy.net.Controllers
|
|||||||
this.douyinFollowService = douyinFollowService;
|
this.douyinFollowService = douyinFollowService;
|
||||||
this.douyinCookieService = douyinCookieService;
|
this.douyinCookieService = douyinCookieService;
|
||||||
this.httpClientService = httpClientService;
|
this.httpClientService = httpClientService;
|
||||||
|
_qrCodeService = qrCodeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -484,5 +486,65 @@ namespace dy.net.Controllers
|
|||||||
return ApiResult.Fail("文件不存在");
|
return ApiResult.Fail("文件不存在");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取抖音扫码登录二维码
|
||||||
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("qrcode")]
|
||||||
|
public async Task<IActionResult> GetQrCode()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (token, qrBase64) = await _qrCodeService.GetQrCodeAsync();
|
||||||
|
return Ok(new { code = 0, data = new { token, qrBase64 } });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Ok(new { code = -1, error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 轮询扫码状态(前端每2秒调一次)
|
||||||
|
/// status: 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期
|
||||||
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("qrstatus")]
|
||||||
|
public async Task<IActionResult> GetQrStatus([FromQuery] string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (status, cookies, secUserId) = await _qrCodeService.CheckStatusAsync(token);
|
||||||
|
return Ok(new { code = 0, data = new { status, cookies, secUserId } });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error($"查询扫码状态失败: {ex.Message}");
|
||||||
|
return Ok(new { code = -1, error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 扫码成功后,将 Cookie 绑定到指定账号
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("qrbind")]
|
||||||
|
public async Task<IActionResult> BindQrCode([FromQuery] string token, [FromQuery] string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (cookies, secUserId) = _qrCodeService.GetSessionResult(token);
|
||||||
|
if (string.IsNullOrEmpty(cookies))
|
||||||
|
return Ok(new { code = -1, error = "扫码尚未完成或已过期" });
|
||||||
|
|
||||||
|
var result = await douyinCookieService.FastResetCookie(id, cookies);
|
||||||
|
_qrCodeService.RemoveSession(token);
|
||||||
|
return Ok(new { code = result ? 0 : -1, secUserId });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Ok(new { code = -1, error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 多阶段构建 Dockerfile
|
||||||
|
# 第一阶段:构建前端
|
||||||
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
COPY app/package.json app/pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
npm install -g pnpm && \
|
||||||
|
pnpm config set registry https://registry.npmmirror.com && \
|
||||||
|
if [ -f pnpm-lock.yaml ]; then \
|
||||||
|
pnpm install --frozen-lockfile; \
|
||||||
|
else \
|
||||||
|
npm install; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
COPY app/ ./
|
||||||
|
RUN pnpm build || npm run build
|
||||||
|
|
||||||
|
# 第二阶段:构建后端 + 下载 Playwright Chromium
|
||||||
|
FROM mcr.azurecr.cn/dotnet/sdk:6.0 AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# 安装 Node.js 18(用于 npx playwright install,最可靠的方式)
|
||||||
|
RUN apt-get update && apt-get install -y curl && \
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY *.csproj ./
|
||||||
|
RUN dotnet restore dy.net.csproj
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN find . -name "._*" -type f -delete
|
||||||
|
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./app/dist
|
||||||
|
|
||||||
|
RUN dotnet publish dy.net.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
# 用 npx 下载 Playwright Chromium 到固定路径
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
RUN npx -y playwright@1.44.0 install chromium
|
||||||
|
|
||||||
|
# 第三阶段:运行镜像
|
||||||
|
FROM mcr.azurecr.cn/dotnet/aspnet:6.0 AS final
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# 切换阿里云镜像源,解决国内网络无法访问 deb.debian.org 的问题
|
||||||
|
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \
|
||||||
|
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
|
||||||
|
|
||||||
|
# 安装 Chromium 运行时依赖(Debian Bullseye)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
fonts-liberation \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=backend-builder /app/publish .
|
||||||
|
COPY --from=backend-builder /ms-playwright /ms-playwright
|
||||||
|
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
EXPOSE 10101
|
||||||
|
ENV ASPNETCORE_URLS=http://*:10101
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "dy.net.dll"]
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="visible"
|
||||||
|
:width="900"
|
||||||
|
:footer="null"
|
||||||
|
:maskClosable="false"
|
||||||
|
@cancel="handleClose"
|
||||||
|
class="video-player-modal"
|
||||||
|
:destroyOnClose="true"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="modal-title-wrapper">
|
||||||
|
<span class="modal-title-text">{{ modalTitle }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="video-player-container" v-if="currentVideoId">
|
||||||
|
<!-- 视频信息 -->
|
||||||
|
<div class="video-info" v-if="currentVideoInfo">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">作者:</span>
|
||||||
|
<span class="value">{{ currentVideoInfo.author || '未知' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">分类:</span>
|
||||||
|
<span class="value">{{ currentVideoInfo.viedoCate || '未分类' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">分辨率:</span>
|
||||||
|
<span class="value">{{ currentVideoInfo.resolution || '未知' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">文件大小:</span>
|
||||||
|
<span class="value">{{ formatFileSize(currentVideoInfo.fileSize || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频播放器 -->
|
||||||
|
<div class="video-wrapper" v-if="currentVideoId">
|
||||||
|
<video
|
||||||
|
ref="videoPlayer"
|
||||||
|
:src="videoUrl"
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
class="video-element"
|
||||||
|
@error="handleVideoError"
|
||||||
|
@loadstart="handleLoadStart"
|
||||||
|
@loadedmetadata="handleLoadedMetadata"
|
||||||
|
@canplay="handleCanPlay"
|
||||||
|
@canplaythrough="handleCanPlayThrough"
|
||||||
|
@progress="handleProgress"
|
||||||
|
@waiting="handleWaiting"
|
||||||
|
@playing="handlePlaying"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<div v-else class="video-wrapper">
|
||||||
|
<a-empty description="视频信息加载中..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<a-spin size="large" tip="加载视频中..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
<a-alert
|
||||||
|
:message="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="error = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import Cookie from 'js-cookie';
|
||||||
|
|
||||||
|
interface VideoInfo {
|
||||||
|
id: string;
|
||||||
|
videoTitle: string;
|
||||||
|
author: string;
|
||||||
|
viedoCate: string;
|
||||||
|
resolution: string;
|
||||||
|
fileSize: number;
|
||||||
|
videoSavePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
videoId?: string;
|
||||||
|
videoInfo?: VideoInfo;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const videoPlayer = ref<HTMLVideoElement | null>(null);
|
||||||
|
const currentVideoId = ref<string>('');
|
||||||
|
const currentVideoInfo = ref<VideoInfo | null>(null);
|
||||||
|
const videoDirectUrl = ref<string>('');
|
||||||
|
|
||||||
|
// 视频URL - 直接使用流式 URL,让浏览器处理 Range 请求
|
||||||
|
const videoUrl = computed(() => videoDirectUrl.value);
|
||||||
|
|
||||||
|
// 标题(完整显示,支持多行)
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
return currentVideoInfo.value?.videoTitle || '视频预览';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置视频流 URL,让浏览器直接处理 Range 请求实现流式播放
|
||||||
|
const loadVideoStream = async (videoId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
// 构建视频流 URL,浏览器会自动发送 Range 请求
|
||||||
|
// 添加时间戳避免缓存问题
|
||||||
|
const url = `/api/video/stream/${videoId}?t=${Date.now()}`;
|
||||||
|
console.log('[VideoPlayer] 设置视频流 URL:', url);
|
||||||
|
|
||||||
|
// 直接使用 URL,让浏览器处理 Range 请求
|
||||||
|
// 浏览器会自动发送 Range 请求头,实现边加载边播放
|
||||||
|
videoDirectUrl.value = url;
|
||||||
|
|
||||||
|
// 确保 video 元素存在,并且等待 DOM 更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
// 先清空 src,避免使用旧的 URL
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
|
||||||
|
// 等待一下,确保清空操作完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// 设置新的视频 URL
|
||||||
|
videoPlayer.value.src = videoDirectUrl.value;
|
||||||
|
console.log('[VideoPlayer] 视频 src 已设置:', videoDirectUrl.value);
|
||||||
|
|
||||||
|
// 加载视频元数据(不等待完整下载)
|
||||||
|
// 浏览器会自动发送 Range 请求,只加载必要的部分
|
||||||
|
videoPlayer.value.load();
|
||||||
|
console.log('[VideoPlayer] 调用 video.load(),开始加载视频');
|
||||||
|
|
||||||
|
// 视频元素会自动处理 Range 请求,实现流式播放
|
||||||
|
// 当有足够的数据可以播放时(通常只需要几秒的数据),浏览器就可以开始播放
|
||||||
|
// 不需要等待整个文件下载完成
|
||||||
|
|
||||||
|
// 监听网络状态变化,用于调试
|
||||||
|
const checkNetworkState = () => {
|
||||||
|
const networkState = videoPlayer.value?.networkState;
|
||||||
|
const readyState = videoPlayer.value?.readyState;
|
||||||
|
const buffered = videoPlayer.value?.buffered;
|
||||||
|
|
||||||
|
console.log('[VideoPlayer] 网络状态:', {
|
||||||
|
networkState,
|
||||||
|
readyState,
|
||||||
|
bufferedLength: buffered?.length || 0,
|
||||||
|
bufferedRanges: buffered?.length ? Array.from({ length: buffered.length }, (_, i) => ({
|
||||||
|
start: buffered.start(i),
|
||||||
|
end: buffered.end(i)
|
||||||
|
})) : []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定期检查网络状态(用于调试)
|
||||||
|
const networkCheckInterval = setInterval(() => {
|
||||||
|
if (videoPlayer.value && loading.value) {
|
||||||
|
checkNetworkState();
|
||||||
|
} else {
|
||||||
|
clearInterval(networkCheckInterval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 5秒后停止检查
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(networkCheckInterval);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[VideoPlayer] 设置视频流 URL 失败:', err);
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
// 根据错误类型设置错误消息
|
||||||
|
if (err.message) {
|
||||||
|
error.value = err.message;
|
||||||
|
} else {
|
||||||
|
error.value = '视频加载失败,请稍后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空 URL
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开播放器
|
||||||
|
const open = async (id?: string, info?: VideoInfo) => {
|
||||||
|
console.log('VideoPlayer.open called', { id, info });
|
||||||
|
if (!id && !info?.id) {
|
||||||
|
message.error('视频ID不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = id || info!.id;
|
||||||
|
const videoInfo = info || {
|
||||||
|
id: videoId,
|
||||||
|
videoTitle: '视频预览',
|
||||||
|
author: '未知',
|
||||||
|
viedoCate: '未分类',
|
||||||
|
resolution: '未知',
|
||||||
|
fileSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('设置视频信息', { videoId, videoInfo });
|
||||||
|
|
||||||
|
// 先清空之前的状态
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.pause();
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
}
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
error.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// 设置视频信息
|
||||||
|
currentVideoId.value = videoId;
|
||||||
|
currentVideoInfo.value = videoInfo;
|
||||||
|
|
||||||
|
// 先显示模态框
|
||||||
|
visible.value = true;
|
||||||
|
|
||||||
|
// 等待 DOM 渲染完成后再加载视频流
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 加载视频流
|
||||||
|
await loadVideoStream(videoId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭播放器
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false;
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.pause();
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
}
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
loading.value = false;
|
||||||
|
error.value = '';
|
||||||
|
currentVideoId.value = '';
|
||||||
|
currentVideoInfo.value = null;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频加载开始
|
||||||
|
const handleLoadStart = () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
console.log('[VideoPlayer] loadstart 事件触发');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频元数据加载完成
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
if (video) {
|
||||||
|
console.log('[VideoPlayer] loadedmetadata 事件触发', {
|
||||||
|
duration: video.duration,
|
||||||
|
videoWidth: video.videoWidth,
|
||||||
|
videoHeight: video.videoHeight,
|
||||||
|
readyState: video.readyState,
|
||||||
|
networkState: video.networkState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 不在这里关闭 loading,等待 canplay 事件
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频可以开始播放(有足够的数据)
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
loading.value = false;
|
||||||
|
if (video) {
|
||||||
|
console.log('[VideoPlayer] canplay 事件触发 - 视频可以开始播放', {
|
||||||
|
duration: video.duration,
|
||||||
|
readyState: video.readyState,
|
||||||
|
networkState: video.networkState,
|
||||||
|
bufferedLength: video.buffered?.length || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 如果视频还没有播放,可以尝试自动播放(需要用户交互)
|
||||||
|
if (video && video.paused) {
|
||||||
|
// 注意:自动播放可能被浏览器阻止,需要用户交互
|
||||||
|
// 这里不自动播放,让用户点击播放按钮
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频可以流畅播放(有足够的数据缓冲)
|
||||||
|
const handleCanPlayThrough = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
loading.value = false;
|
||||||
|
if (video) {
|
||||||
|
console.log('[VideoPlayer] canplaythrough 事件触发 - 视频可以流畅播放', {
|
||||||
|
duration: video.duration,
|
||||||
|
readyState: video.readyState,
|
||||||
|
networkState: video.networkState,
|
||||||
|
bufferedLength: video.buffered?.length || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频加载进度
|
||||||
|
const handleProgress = () => {
|
||||||
|
const video = videoPlayer.value;
|
||||||
|
if (video && video.buffered.length > 0) {
|
||||||
|
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||||
|
const bufferedPercent = video.duration > 0 ? (bufferedEnd / video.duration) * 100 : 0;
|
||||||
|
console.log('[VideoPlayer] progress 事件 - 缓冲进度:', {
|
||||||
|
bufferedPercent: bufferedPercent.toFixed(2) + '%',
|
||||||
|
bufferedEnd,
|
||||||
|
duration: video.duration,
|
||||||
|
readyState: video.readyState
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果已经有足够的数据可以播放,关闭加载状态
|
||||||
|
if (bufferedEnd > 0 && video.readyState >= 2) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频等待数据
|
||||||
|
const handleWaiting = () => {
|
||||||
|
console.log('[VideoPlayer] waiting 事件 - 视频等待数据');
|
||||||
|
loading.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频开始播放
|
||||||
|
const handlePlaying = () => {
|
||||||
|
console.log('[VideoPlayer] playing 事件 - 视频开始播放');
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频加载错误
|
||||||
|
const handleVideoError = (e: Event) => {
|
||||||
|
const video = e.target as HTMLVideoElement;
|
||||||
|
|
||||||
|
// 如果 src 为空或正在加载中,忽略错误(可能是时序问题)
|
||||||
|
if (!video.src || !videoDirectUrl.value || video.networkState === 0) {
|
||||||
|
console.log('忽略视频加载错误(可能是时序问题):', {
|
||||||
|
src: video.src,
|
||||||
|
directUrl: videoDirectUrl.value,
|
||||||
|
networkState: video.networkState,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('视频加载错误详情:', {
|
||||||
|
error: video.error,
|
||||||
|
errorCode: video.error?.code,
|
||||||
|
errorMessage: video.error?.message,
|
||||||
|
networkState: video.networkState,
|
||||||
|
readyState: video.readyState,
|
||||||
|
src: video.src,
|
||||||
|
directUrl: videoDirectUrl.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (video.error) {
|
||||||
|
switch (video.error.code) {
|
||||||
|
case 1: // MEDIA_ERR_ABORTED
|
||||||
|
error.value = '视频加载被中止';
|
||||||
|
break;
|
||||||
|
case 2: // MEDIA_ERR_NETWORK
|
||||||
|
// 网络错误可能是认证失败(401)或其他网络问题
|
||||||
|
// 检查是否是认证问题
|
||||||
|
if (video.networkState === 3) { // NETWORK_NO_SOURCE
|
||||||
|
// 可能是认证失败,尝试提示用户
|
||||||
|
error.value = '视频加载失败,可能是认证问题。请检查是否已登录,或刷新页面后重试';
|
||||||
|
} else {
|
||||||
|
error.value = '网络错误,请检查网络连接';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 3: // MEDIA_ERR_DECODE
|
||||||
|
error.value = '视频解码失败,可能是视频格式问题或文件损坏';
|
||||||
|
break;
|
||||||
|
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||||
|
// 尝试重新加载视频
|
||||||
|
if (videoDirectUrl.value && video.src === videoDirectUrl.value) {
|
||||||
|
console.log('尝试重新加载视频...');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoPlayer.value && videoDirectUrl.value) {
|
||||||
|
videoPlayer.value.load();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return; // 不显示错误,等待重试
|
||||||
|
}
|
||||||
|
error.value = '视频格式不支持。请检查:1) 视频文件是否完整 2) 浏览器是否支持该格式 3) 文件扩展名是否正确';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
error.value = `视频加载失败 (错误代码: ${video.error.code})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = '视频加载失败,请检查网络连接和视频文件';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听视频加载完成
|
||||||
|
watch(
|
||||||
|
() => videoPlayer.value?.readyState,
|
||||||
|
(readyState) => {
|
||||||
|
if (readyState && readyState >= 2) {
|
||||||
|
// HAVE_CURRENT_DATA 或更高
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (videoPlayer.value) {
|
||||||
|
videoPlayer.value.pause();
|
||||||
|
videoPlayer.value.src = '';
|
||||||
|
}
|
||||||
|
videoDirectUrl.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
close: handleClose,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.video-player-modal {
|
||||||
|
:deep(.ant-modal-body) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保标题区域不遮挡关闭按钮,支持多行显示
|
||||||
|
:deep(.ant-modal-header) {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 56px !important; // 为关闭按钮留出足够空间
|
||||||
|
min-height: 55px;
|
||||||
|
padding-bottom: 16px; // 增加底部内边距,为多行标题留空间
|
||||||
|
|
||||||
|
.ant-modal-title {
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义标题样式
|
||||||
|
.modal-title-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(100% - 56px); // 减去关闭按钮的宽度
|
||||||
|
padding-right: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-text {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保关闭按钮始终可见且在最上层
|
||||||
|
:deep(.ant-modal-close) {
|
||||||
|
position: absolute !important;
|
||||||
|
right: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
width: 56px !important;
|
||||||
|
height: 55px !important;
|
||||||
|
line-height: 55px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-close-x {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.video-element {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
|
<PackageReference Include="Microsoft.Playwright" Version="1.44.0" />
|
||||||
<!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />-->
|
<!--<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />-->
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ namespace dy.net.extension
|
|||||||
});
|
});
|
||||||
|
|
||||||
services.AddScoped<DouyinQuartzJobService>();
|
services.AddScoped<DouyinQuartzJobService>();
|
||||||
|
// 扫码登录服务(Playwright,全局单例避免重复初始化)
|
||||||
|
services.AddSingleton<DyQrCodeService>();
|
||||||
|
services.AddScoped<DownloadConfigService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -120,6 +120,38 @@ namespace dy.net.model.entity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[SugarColumn(IsNullable = true)]
|
[SugarColumn(IsNullable = true)]
|
||||||
public bool CloseNfo { get; set; }
|
public bool CloseNfo { get; set; }
|
||||||
|
|
||||||
|
// -------- 下载配置扩展字段(本地新增)--------
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用自动下载
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public bool? EnableAutoDownload { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 是否下载喜欢视频(videoType=1)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public bool? DownloadFavorite { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 是否下载收藏视频(videoType=2)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public bool? DownloadCollect { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 下载时间范围(天),0 表示不限
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(IsNullable = true)]
|
||||||
|
public int? DownloadDaysRange { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 作者白名单(JSON 数组字符串)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 2000, IsNullable = true)]
|
||||||
|
public string AuthorWhitelist { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 作者黑名单(JSON 数组字符串)
|
||||||
|
/// </summary>
|
||||||
|
[SugarColumn(Length = 2000, IsNullable = true)]
|
||||||
|
public string AuthorBlacklist { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using dy.net.model.entity;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace dy.net.service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 下载配置服务,用于判断是否应该下载视频
|
||||||
|
/// </summary>
|
||||||
|
public class DownloadConfigService
|
||||||
|
{
|
||||||
|
private readonly DouyinCommonService _commonService;
|
||||||
|
|
||||||
|
public DownloadConfigService(DouyinCommonService commonService)
|
||||||
|
{
|
||||||
|
_commonService = commonService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppConfig GetConfig()
|
||||||
|
{
|
||||||
|
return _commonService.GetConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断是否应该下载视频
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="videoType">视频类型:"1"=喜欢,"2"=收藏</param>
|
||||||
|
/// <param name="authorId">作者ID</param>
|
||||||
|
/// <param name="authorName">作者名称</param>
|
||||||
|
/// <param name="createTime">视频创建时间</param>
|
||||||
|
/// <returns>true=应该下载,false=不应该下载</returns>
|
||||||
|
public bool ShouldDownload(string videoType, string authorId, string authorName, DateTime createTime)
|
||||||
|
{
|
||||||
|
var config = GetConfig();
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果未启用自动下载,直接返回false
|
||||||
|
if (config.EnableAutoDownload != true)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查视频类型是否允许下载
|
||||||
|
if (videoType == "1" && config.DownloadFavorite != true)
|
||||||
|
{
|
||||||
|
return false; // 喜欢类型但未启用下载
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoType == "2" && config.DownloadCollect != true)
|
||||||
|
{
|
||||||
|
return false; // 收藏类型但未启用下载
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查时间范围
|
||||||
|
if (config.DownloadDaysRange.HasValue && config.DownloadDaysRange.Value > 0)
|
||||||
|
{
|
||||||
|
var daysDiff = (DateTime.Now - createTime).TotalDays;
|
||||||
|
if (daysDiff > config.DownloadDaysRange.Value)
|
||||||
|
{
|
||||||
|
return false; // 超出时间范围
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查作者白名单
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.AuthorWhitelist))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var whitelist = JsonConvert.DeserializeObject<List<string>>(config.AuthorWhitelist);
|
||||||
|
if (whitelist != null && whitelist.Any())
|
||||||
|
{
|
||||||
|
// 如果配置了白名单,只有白名单中的作者才下载
|
||||||
|
var isInWhitelist = whitelist.Any(a =>
|
||||||
|
(!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
);
|
||||||
|
if (!isInWhitelist)
|
||||||
|
{
|
||||||
|
return false; // 不在白名单中
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"解析作者白名单失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查作者黑名单
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.AuthorBlacklist))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var blacklist = JsonConvert.DeserializeObject<List<string>>(config.AuthorBlacklist);
|
||||||
|
if (blacklist != null && blacklist.Any())
|
||||||
|
{
|
||||||
|
// 如果配置了黑名单,黑名单中的作者不下载
|
||||||
|
var isInBlacklist = blacklist.Any(a =>
|
||||||
|
(!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
);
|
||||||
|
if (isInBlacklist)
|
||||||
|
{
|
||||||
|
return false; // 在黑名单中
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning($"解析作者黑名单失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有检查都通过,可以下载
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace dy.net.service
|
||||||
|
{
|
||||||
|
public class DyQrCodeService
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, QrSession> _sessions = new();
|
||||||
|
|
||||||
|
private class QrSession
|
||||||
|
{
|
||||||
|
public IPlaywright Playwright { get; set; }
|
||||||
|
public IBrowser Browser { get; set; }
|
||||||
|
public IBrowserContext Context { get; set; }
|
||||||
|
public IPage Page { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||||
|
public string FinalCookies { get; set; }
|
||||||
|
public string SecUserId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string LoginUrl = "https://creator.douyin.com/creator-micro/login";
|
||||||
|
// QR 码图片的 class 名含 qrcode_img(后缀随版本变化,用属性选择器匹配)
|
||||||
|
private const string QrSelector = "img[class*='qrcode_img']";
|
||||||
|
|
||||||
|
public async Task<(string token, string qrBase64)> GetQrCodeAsync()
|
||||||
|
{
|
||||||
|
CleanExpiredSessions();
|
||||||
|
|
||||||
|
var playwright = await Playwright.CreateAsync();
|
||||||
|
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||||
|
{
|
||||||
|
Headless = true,
|
||||||
|
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage" }
|
||||||
|
});
|
||||||
|
var context = await browser.NewContextAsync(new BrowserNewContextOptions
|
||||||
|
{
|
||||||
|
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
||||||
|
});
|
||||||
|
var page = await context.NewPageAsync();
|
||||||
|
|
||||||
|
await page.GotoAsync(LoginUrl, new PageGotoOptions { Timeout = 15000 });
|
||||||
|
await page.WaitForSelectorAsync(QrSelector, new PageWaitForSelectorOptions { Timeout = 10000 });
|
||||||
|
|
||||||
|
// img.src 已经是 data:image/png;base64,... 直接取用
|
||||||
|
var src = await page.EvaluateAsync<string>($"() => document.querySelector(\"{QrSelector}\").src");
|
||||||
|
var qrBase64 = src.Replace("data:image/png;base64,", "");
|
||||||
|
|
||||||
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
_sessions[token] = new QrSession
|
||||||
|
{
|
||||||
|
Playwright = playwright,
|
||||||
|
Browser = browser,
|
||||||
|
Context = context,
|
||||||
|
Page = page
|
||||||
|
};
|
||||||
|
|
||||||
|
return (token, qrBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(int status, string cookies, string secUserId)> CheckStatusAsync(string token)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(token, out var session))
|
||||||
|
return (-1, null, null);
|
||||||
|
|
||||||
|
if (DateTime.Now - session.CreatedAt > TimeSpan.FromMinutes(5))
|
||||||
|
{
|
||||||
|
await CloseSessionAsync(token, session);
|
||||||
|
return (-1, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已经登录过,直接返回缓存
|
||||||
|
if (session.FinalCookies != null)
|
||||||
|
return (3, session.FinalCookies, session.SecUserId);
|
||||||
|
|
||||||
|
var url = session.Page.Url;
|
||||||
|
|
||||||
|
// 还在登录页
|
||||||
|
if (url.Contains("/creator-micro/login") || url.Contains("sso.douyin.com"))
|
||||||
|
{
|
||||||
|
// 判断是否已扫码(页面出现"确认"或"已扫码"文字)
|
||||||
|
var scanned = await session.Page.EvaluateAsync<bool>(
|
||||||
|
"() => { const t = document.body.innerText; return t.includes('已扫码') || t.includes('确认登录'); }"
|
||||||
|
);
|
||||||
|
return (scanned ? 2 : 1, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 已离开登录页,说明登录成功
|
||||||
|
var browserCookies = await session.Context.CookiesAsync(new[]
|
||||||
|
{
|
||||||
|
"https://creator.douyin.com",
|
||||||
|
"https://www.douyin.com",
|
||||||
|
"https://sso.douyin.com"
|
||||||
|
});
|
||||||
|
var cookieStr = string.Join("; ", browserCookies.Select(c => $"{c.Name}={c.Value}"));
|
||||||
|
|
||||||
|
// 尝试从页面获取 sec_user_id
|
||||||
|
var secUserId = await GetSecUserIdAsync(session.Page);
|
||||||
|
|
||||||
|
session.FinalCookies = cookieStr;
|
||||||
|
session.SecUserId = secUserId;
|
||||||
|
|
||||||
|
// 登录完成后关闭浏览器释放资源
|
||||||
|
_ = Task.Run(async () => await CloseSessionAsync(token, session));
|
||||||
|
|
||||||
|
return (3, cookieStr, secUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (string cookies, string secUserId) GetSessionResult(string token)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(token, out var session))
|
||||||
|
return (session.FinalCookies ?? "", session.SecUserId ?? "");
|
||||||
|
return ("", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveSession(string token)
|
||||||
|
{
|
||||||
|
if (_sessions.TryRemove(token, out var session))
|
||||||
|
_ = Task.Run(async () => await CloseSessionAsync(token, session));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> GetSecUserIdAsync(IPage page)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 从页面 URL 或 API 提取 sec_user_id
|
||||||
|
var resp = await page.EvaluateAsync<string>(
|
||||||
|
"async () => { const r = await fetch('/web/api/userinfo/'); const j = await r.json(); return j?.data?.sec_uid || j?.sec_uid || ''; }"
|
||||||
|
);
|
||||||
|
return resp ?? "";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CloseSessionAsync(string token, QrSession session)
|
||||||
|
{
|
||||||
|
_sessions.TryRemove(token, out _);
|
||||||
|
try { await session.Browser?.CloseAsync(); } catch { }
|
||||||
|
try { session.Playwright?.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CleanExpiredSessions()
|
||||||
|
{
|
||||||
|
var expired = _sessions
|
||||||
|
.Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(6))
|
||||||
|
.ToList();
|
||||||
|
foreach (var kv in expired)
|
||||||
|
_ = Task.Run(async () => await CloseSessionAsync(kv.Key, kv.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user