Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3df41904 | ||
|
|
561d74e16d | ||
|
|
cfe21d8337 | ||
|
|
3a76f9d0cf | ||
|
|
ad7ff7a385 | ||
|
|
c7e2b4d363 | ||
|
|
d5baa79448 |
@@ -27,12 +27,18 @@ node --version
|
||||
|
||||
# 检查 FFmpeg
|
||||
ffmpeg -version
|
||||
|
||||
# 检查 pm2 (用于服务管理)
|
||||
pm2 --version
|
||||
```
|
||||
|
||||
如果缺少 FFmpeg:
|
||||
如果缺少依赖:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# 安装 pm2
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
---
|
||||
@@ -48,7 +54,30 @@ cd /home/rongye/ProgramFiles/ViGent2
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3: 安装后端依赖
|
||||
## 步骤 3: 部署 AI 模型 (LatentSync 1.6)
|
||||
|
||||
> ⚠️ **重要**:LatentSync 需要独立的 Conda 环境和 **~18GB VRAM**。请**不要**直接安装在后端环境中。
|
||||
|
||||
请参考详细的独立部署指南:
|
||||
**[LatentSync 部署指南](../models/LatentSync/DEPLOY.md)**
|
||||
|
||||
该指南包含以下关键步骤,请务必严格按照文档操作:
|
||||
1. 创建独立的 `latentsync` Conda 环境
|
||||
2. 安装 PyTorch 2.5.1 和相关依赖
|
||||
3. 下载模型权重 (HuggingFace CLI)
|
||||
4. 复制核心推理代码
|
||||
5. 验证推理脚本
|
||||
|
||||
**验证 LatentSync 部署**:
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
|
||||
conda activate latentsync
|
||||
python -m scripts.server # 测试能否启动,Ctrl+C 退出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4: 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
@@ -69,47 +98,16 @@ playwright install chromium
|
||||
|
||||
---
|
||||
|
||||
## 步骤 4: 部署 AI 模型 (LatentSync 1.6)
|
||||
|
||||
> ⚠️ **重要**:LatentSync 需要独立的 Conda 环境和 **~18GB VRAM**。请**不要**直接安装在后端环境中。
|
||||
|
||||
请参考详细的独立部署指南:
|
||||
**[LatentSync 部署指南](../models/LatentSync/DEPLOY.md)**
|
||||
|
||||
该指南包含以下关键步骤,请务必严格按照文档操作:
|
||||
1. 创建独立的 `latentsync` Conda 环境
|
||||
2. 安装 PyTorch 2.5.1 和相关依赖
|
||||
3. 下载模型权重 (HuggingFace CLI)
|
||||
4. 复制核心推理代码
|
||||
5. 验证推理脚本
|
||||
|
||||
确保 LatentSync 部署成功后,再继续后续步骤。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 5: 启动 LatentSync 常驻加速服务 (可选)
|
||||
|
||||
为了消除每次生成视频时的 30-40秒 模型加载时间,建议启动常驻服务:
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
|
||||
|
||||
# 后台启动服务 (自动读取 backend/.env 中的 GPU 配置)
|
||||
nohup python -m scripts.server > server.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 7: 配置环境变量
|
||||
## 步骤 5: 配置环境变量
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
|
||||
# 复制配置模板 (默认配置已经就绪)
|
||||
# 复制配置模板
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
> 💡 **说明**:`.env.example` 已包含正确的 LatentSync 默认配置,直接复制即可使用。
|
||||
> 💡 **说明**:`.env.example` 已包含正确的默认配置,直接复制即可使用。
|
||||
> 如需自定义,可编辑 `.env` 修改以下参数:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
@@ -122,20 +120,25 @@ cp .env.example .env
|
||||
|
||||
---
|
||||
|
||||
## 步骤 8: 安装前端依赖
|
||||
## 步骤 6: 安装前端依赖
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 生产环境构建 (可选)
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤 9: 测试运行
|
||||
## 步骤 7: 测试运行
|
||||
|
||||
### 启动后端
|
||||
> 💡 先手动启动测试,确认一切正常后再配置 pm2 常驻服务。
|
||||
|
||||
### 启动后端 (终端 1)
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
@@ -143,16 +146,22 @@ source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
```
|
||||
|
||||
### 启动前端 (新开终端)
|
||||
### 启动前端 (终端 2)
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/frontend
|
||||
npm run dev -- -H 0.0.0.0 --port 3002
|
||||
```
|
||||
|
||||
---
|
||||
### 启动 LatentSync (终端 3, 可选加速)
|
||||
|
||||
## 步骤 10: 验证
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
|
||||
conda activate latentsync
|
||||
python -m scripts.server
|
||||
```
|
||||
|
||||
### 验证
|
||||
|
||||
1. 访问 http://服务器IP:3002 查看前端
|
||||
2. 访问 http://服务器IP:8006/docs 查看 API 文档
|
||||
@@ -160,53 +169,72 @@ npm run dev -- -H 0.0.0.0 --port 3002
|
||||
|
||||
---
|
||||
|
||||
## 使用 systemd 管理服务 (可选)
|
||||
## 步骤 8: 使用 pm2 管理常驻服务
|
||||
|
||||
### 后端服务
|
||||
> 推荐使用 pm2 管理所有服务,支持自动重启和日志管理。
|
||||
|
||||
创建 `/etc/systemd/system/vigent2-backend.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ViGent2 Backend API
|
||||
After=network.target
|
||||
### 创建 pm2 配置文件
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rongye
|
||||
WorkingDirectory=/home/rongye/ProgramFiles/ViGent2/backend
|
||||
Environment="PATH=/home/rongye/ProgramFiles/ViGent2/backend/venv/bin"
|
||||
ExecStart=/home/rongye/ProgramFiles/ViGent2/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
Restart=always
|
||||
创建 `/home/rongye/ProgramFiles/ViGent2/ecosystem.config.js`:
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'vigent2-backend',
|
||||
cwd: '/home/rongye/ProgramFiles/ViGent2/backend',
|
||||
script: 'venv/bin/uvicorn',
|
||||
args: 'app.main:app --host 0.0.0.0 --port 8006',
|
||||
interpreter: 'none',
|
||||
env: {
|
||||
PATH: '/home/rongye/ProgramFiles/ViGent2/backend/venv/bin:' + process.env.PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'vigent2-frontend',
|
||||
cwd: '/home/rongye/ProgramFiles/ViGent2/frontend',
|
||||
script: 'npm',
|
||||
args: 'run start',
|
||||
env: {
|
||||
PORT: 3002
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'vigent2-latentsync',
|
||||
cwd: '/home/rongye/ProgramFiles/ViGent2/models/LatentSync',
|
||||
script: 'python',
|
||||
args: '-m scripts.server',
|
||||
interpreter: '/home/rongye/miniconda3/envs/latentsync/bin/python'
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### 前端服务
|
||||
|
||||
创建 `/etc/systemd/system/vigent2-frontend.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ViGent2 Frontend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rongye
|
||||
WorkingDirectory=/home/rongye/ProgramFiles/ViGent2/frontend
|
||||
ExecStart=/usr/bin/npm run start
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 启用服务
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable vigent2-backend vigent2-frontend
|
||||
sudo systemctl start vigent2-backend vigent2-frontend
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
|
||||
# 启动所有服务
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
|
||||
# 设置开机自启
|
||||
pm2 save
|
||||
pm2 startup # 按提示执行生成的命令
|
||||
```
|
||||
|
||||
### pm2 常用命令
|
||||
|
||||
```bash
|
||||
pm2 status # 查看所有服务状态
|
||||
pm2 logs # 查看所有日志
|
||||
pm2 logs vigent2-backend # 查看后端日志
|
||||
pm2 restart all # 重启所有服务
|
||||
pm2 stop vigent2-latentsync # 停止 LatentSync 服务
|
||||
pm2 delete all # 删除所有服务
|
||||
```
|
||||
|
||||
---
|
||||
@@ -227,14 +255,45 @@ python3 -c "import torch; print(torch.cuda.is_available())"
|
||||
# 查看端口占用
|
||||
sudo lsof -i :8006
|
||||
sudo lsof -i :3002
|
||||
sudo lsof -i :8007
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# 后端日志
|
||||
journalctl -u vigent2-backend -f
|
||||
|
||||
# 前端日志
|
||||
journalctl -u vigent2-frontend -f
|
||||
# pm2 日志
|
||||
pm2 logs vigent2-backend
|
||||
pm2 logs vigent2-frontend
|
||||
pm2 logs vigent2-latentsync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖清单
|
||||
|
||||
### 后端关键依赖
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| `fastapi` | Web API 框架 |
|
||||
| `uvicorn` | ASGI 服务器 |
|
||||
| `edge-tts` | 微软 TTS 配音 |
|
||||
| `playwright` | 社交媒体自动发布 |
|
||||
| `biliup` | B站视频上传 |
|
||||
| `loguru` | 日志管理 |
|
||||
|
||||
### 前端关键依赖
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| `next` | React 框架 |
|
||||
| `swr` | 数据请求与缓存 |
|
||||
| `tailwindcss` | CSS 样式 |
|
||||
|
||||
### LatentSync 关键依赖
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| `torch` 2.5.1 | PyTorch GPU 推理 |
|
||||
| `diffusers` | Latent Diffusion 模型 |
|
||||
| `accelerate` | 模型加速 |
|
||||
|
||||
113
Docs/DevLogs/Day8.md
Normal file
113
Docs/DevLogs/Day8.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Day 8: 用户体验优化
|
||||
|
||||
**日期**: 2026-01-22
|
||||
**目标**: 文件名保留 + 视频持久化 + 界面优化
|
||||
|
||||
---
|
||||
|
||||
## 📋 任务概览
|
||||
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| 文件名保留 | ✅ 完成 |
|
||||
| 视频持久化 | ✅ 完成 |
|
||||
| 历史视频列表 | ✅ 完成 |
|
||||
| 删除功能 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 实施成果
|
||||
|
||||
### 后端改动
|
||||
|
||||
**修改文件**:
|
||||
- `backend/app/api/materials.py`
|
||||
- ✅ `sanitize_filename()` 文件名安全化
|
||||
- ✅ 时间戳前缀避免冲突 (`{timestamp}_{原始文件名}`)
|
||||
- ✅ `list_materials` 显示原始文件名
|
||||
- ✅ `DELETE /api/materials/{id}` 删除素材
|
||||
|
||||
- `backend/app/api/videos.py`
|
||||
- ✅ `GET /api/videos/generated` 历史视频列表
|
||||
- ✅ `DELETE /api/videos/generated/{id}` 删除视频
|
||||
|
||||
### 前端改动
|
||||
|
||||
**修改文件**:
|
||||
- `frontend/src/app/page.tsx`
|
||||
- ✅ `GeneratedVideo` 类型定义
|
||||
- ✅ `generatedVideos` 状态管理
|
||||
- ✅ `fetchGeneratedVideos()` 获取历史
|
||||
- ✅ `deleteMaterial()` / `deleteVideo()` 删除功能
|
||||
- ✅ 素材卡片添加删除按钮 (hover 显示)
|
||||
- ✅ 历史视频列表组件 (右侧预览区下方)
|
||||
- ✅ 生成完成后自动刷新历史列表
|
||||
|
||||
---
|
||||
|
||||
## 🔧 API 变更
|
||||
|
||||
### 新增端点
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/videos/generated` | 获取生成视频列表 |
|
||||
| DELETE | `/api/videos/generated/{id}` | 删除生成视频 |
|
||||
| DELETE | `/api/materials/{id}` | 删除素材 |
|
||||
|
||||
### 文件命名规则
|
||||
|
||||
```
|
||||
原始: 测试视频.mp4
|
||||
保存: 1737518400_测试视频.mp4
|
||||
显示: 测试视频.mp4 (前端自动去除时间戳前缀)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成总结
|
||||
|
||||
1. **文件名保留** - 上传保留原始名称,时间戳前缀避免冲突
|
||||
2. **视频持久化** - 从文件系统读取,刷新不丢失
|
||||
3. **历史列表** - 右侧显示历史视频,点击切换播放
|
||||
4. **删除功能** - 素材和视频均支持删除
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试清单
|
||||
|
||||
- [x] 上传视频后检查素材列表显示原始文件名
|
||||
- [x] 刷新页面后检查历史视频列表持久化
|
||||
- [x] 测试删除素材功能
|
||||
- [x] 测试删除生成视频功能
|
||||
- [x] 测试历史视频列表点击切换播放
|
||||
|
||||
---
|
||||
|
||||
## 🔧 发布功能修复 (Day 8 下半场)
|
||||
|
||||
> 以下修复在用户体验优化后进行
|
||||
|
||||
### 问题
|
||||
|
||||
1. **抖音 QR 登录假成功** - 前端检测到旧 Cookie 文件就显示"登录成功",实际可能已过期
|
||||
2. **抖音上传循环卡死** - 发布后检测逻辑不完善,`while True` 无超时
|
||||
3. **前端轮询不规范** - 使用 `setInterval` 手动轮询,不符合 React 最佳实践
|
||||
|
||||
### 修复
|
||||
|
||||
**后端**:
|
||||
- `publish_service.py` - 添加 `logout()` 方法、修复 `get_login_session_status()` 优先检查活跃会话
|
||||
- `api/publish.py` - 新增 `POST /api/publish/logout/{platform}` 端点
|
||||
- `douyin_uploader.py` - 添加 `import time`,修复发布按钮点击竞态条件
|
||||
|
||||
**前端**:
|
||||
- `publish/page.tsx` - 使用 `useSWR` 替代 `setInterval` 轮询登录状态
|
||||
- `package.json` - 添加 `swr` 依赖
|
||||
|
||||
### 新增 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/publish/logout/{platform}` | 注销平台登录 |
|
||||
|
||||
119
Docs/DevLogs/Day9.md
Normal file
119
Docs/DevLogs/Day9.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Day 9: 发布模块代码优化
|
||||
|
||||
**日期**: 2026-01-23
|
||||
**目标**: 代码质量优化 + 发布功能验证
|
||||
|
||||
---
|
||||
|
||||
## 📋 任务概览
|
||||
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| B站/抖音发布验证 | ✅ 完成 |
|
||||
| 资源清理保障 (try-finally) | ✅ 完成 |
|
||||
| 超时保护 (消除无限循环) | ✅ 完成 |
|
||||
| 小红书 headless 模式修复 | ✅ 完成 |
|
||||
| API 输入验证 | ✅ 完成 |
|
||||
| 类型提示完善 | ✅ 完成 |
|
||||
| 服务层代码优化 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 发布验证结果
|
||||
|
||||
### 登录功能
|
||||
- ✅ **B站登录成功** - 策略3(Text)匹配,Cookie已保存
|
||||
- ✅ **抖音登录成功** - 策略3(Text)匹配,Cookie已保存
|
||||
|
||||
### 发布功能
|
||||
- ✅ **抖音发布成功** - 自动关闭弹窗、跳转管理页面
|
||||
- ✅ **B站发布成功** - API返回 `bvid: BV14izPBQEbd`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 代码优化
|
||||
|
||||
### 1. 资源清理保障
|
||||
|
||||
**问题**:Playwright 浏览器在异常路径可能未关闭
|
||||
|
||||
**修复**:`try-finally` 模式确保资源释放
|
||||
```python
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context(...)
|
||||
# ... 业务逻辑 ...
|
||||
finally:
|
||||
if context:
|
||||
try: await context.close()
|
||||
except Exception: pass
|
||||
if browser:
|
||||
try: await browser.close()
|
||||
except Exception: pass
|
||||
```
|
||||
|
||||
### 2. 超时保护
|
||||
|
||||
**问题**:`while True` 循环可能导致任务卡死
|
||||
|
||||
**修复**:添加类级别超时常量
|
||||
```python
|
||||
class DouyinUploader(BaseUploader):
|
||||
UPLOAD_TIMEOUT = 300 # 视频上传超时
|
||||
PUBLISH_TIMEOUT = 180 # 发布检测超时
|
||||
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
|
||||
```
|
||||
|
||||
### 3. B站 bvid 提取修复
|
||||
|
||||
**问题**:API 返回的 bvid 在 `data` 字段内
|
||||
|
||||
**修复**:同时检查多个位置
|
||||
```python
|
||||
bvid = ret.get('data', {}).get('bvid') or ret.get('bvid', '')
|
||||
aid = ret.get('data', {}).get('aid') or ret.get('aid', '')
|
||||
```
|
||||
|
||||
### 4. API 输入验证
|
||||
|
||||
**修复**:所有端点添加平台验证
|
||||
```python
|
||||
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
|
||||
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件列表
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `app/api/publish.py` | 输入验证、平台常量、文档改进 |
|
||||
| `app/services/publish_service.py` | 类型提示、平台 enabled 标记 |
|
||||
| `app/services/qr_login_service.py` | 类型提示、修复裸 except、超时常量 |
|
||||
| `app/services/uploader/base_uploader.py` | 类型提示 |
|
||||
| `app/services/uploader/bilibili_uploader.py` | bvid提取修复、类型提示 |
|
||||
| `app/services/uploader/douyin_uploader.py` | 资源清理、超时保护、类型提示 |
|
||||
| `app/services/uploader/xiaohongshu_uploader.py` | headless模式、资源清理、超时保护 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成总结
|
||||
|
||||
1. **发布功能验证通过** - B站/抖音登录和发布均正常
|
||||
2. **代码健壮性提升** - 资源清理、超时保护、异常处理
|
||||
3. **代码可维护性** - 完整类型提示、常量化配置
|
||||
4. **服务器兼容性** - 小红书 headless 模式修复
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [代码审核报告](file:///C:/Users/danny/.gemini/antigravity/brain/a28bb1a6-2929-4c55-b837-c989943844e1/walkthrough.md)
|
||||
- [部署手册](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
|
||||
@@ -198,6 +198,21 @@ ViGent/Docs/
|
||||
|
||||
---
|
||||
|
||||
## 🧾 全局文档更新清单 (Checklist)
|
||||
|
||||
> **每次提交重要变更时,请核对以下文件是否需要同步:**
|
||||
|
||||
| 优先级 | 文件路径 | 检查重点 |
|
||||
| :---: | :--- | :--- |
|
||||
| 🔥 **High** | `Docs/DevLogs/DayN.md` | **(最新日志)** 详细记录变更、修复、代码片段 |
|
||||
| 🔥 **High** | `Docs/task_complete.md` | **(任务总览)** 更新 `[x]`、进度条、时间线 |
|
||||
| ⚡ **Med** | `README.md` | **(项目主页)** 功能特性、技术栈、最新截图 |
|
||||
| ⚡ **Med** | `Docs/DEPLOY_MANUAL.md` | **(部署手册)** 环境变量、依赖包、启动命令变更 |
|
||||
| 🧊 **Low** | `Docs/implementation_plan.md` | **(实施计划)** 核对计划与实际实现的差异 |
|
||||
| 🧊 **Low** | `frontend/README.md` | **(前端文档)** 新页面路由、组件用法、UI变更 |
|
||||
|
||||
---
|
||||
|
||||
## 📅 DayN.md 更新规则(日常更新)
|
||||
|
||||
### 新建判断 (对话开始前)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 后端 (FastAPI) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Celery 任务队列 (Redis) │
|
||||
│ 异步任务队列 (asyncio) │
|
||||
│ ├── 视频生成任务 │
|
||||
│ ├── TTS 配音任务 │
|
||||
│ └── 自动发布任务 │
|
||||
@@ -30,7 +30,7 @@
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ MuseTalk │ │ FFmpeg │ │Playwright│
|
||||
│LatentSync│ │ FFmpeg │ │Playwright│
|
||||
│ 唇形同步 │ │ 视频合成 │ │ 自动发布 │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
@@ -45,7 +45,7 @@
|
||||
| **UI 组件库** | Tailwind + shadcn/ui | Ant Design |
|
||||
| **后端框架** | FastAPI | Flask |
|
||||
| **任务队列** | Celery + Redis | RQ / Dramatiq |
|
||||
| **唇形同步** | MuseTalk | Wav2Lip / SadTalker |
|
||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||
| **声音克隆** | GPT-SoVITS (可选) | - |
|
||||
| **视频处理** | FFmpeg | MoviePy |
|
||||
@@ -269,6 +269,32 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
|
||||
- [x] **常驻模型服务** (Persistent Server, 0s 加载)
|
||||
- [x] **GPU 并发控制** (串行队列防崩溃)
|
||||
|
||||
### 阶段十一:社交媒体发布完善 (Day 7) ✅
|
||||
|
||||
> **目标**:实现全自动扫码登录和多平台发布
|
||||
|
||||
- [x] QR码自动登录 (Playwright headless + Stealth)
|
||||
- [x] 多平台上传器架构 (B站/抖音/小红书)
|
||||
- [x] Cookie 自动管理
|
||||
- [x] 定时发布功能
|
||||
|
||||
### 阶段十二:用户体验优化 (Day 8) ✅
|
||||
|
||||
> **目标**:提升文件管理和历史记录功能
|
||||
|
||||
- [x] 文件名保留 (时间戳前缀 + 原始名称)
|
||||
- [x] 视频持久化 (历史视频列表 API)
|
||||
- [x] 素材/视频删除功能
|
||||
|
||||
### 阶段十三:发布模块优化 (Day 9) ✅
|
||||
|
||||
> **目标**:代码质量优化 + 发布功能验证
|
||||
|
||||
- [x] B站/抖音登录+发布验证通过
|
||||
- [x] 资源清理保障 (try-finally)
|
||||
- [x] 超时保护 (消除无限循环)
|
||||
- [x] 完整类型提示
|
||||
|
||||
---
|
||||
|
||||
## 项目目录结构 (最终)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**项目**:ViGent2 数字人口播视频生成系统
|
||||
**服务器**:Dell R730 (2× RTX 3090 24GB)
|
||||
**更新时间**:2026-01-21
|
||||
**整体进度**:100%(Day 7 社交发布完成)
|
||||
**更新时间**:2026-01-23
|
||||
**整体进度**:100%(Day 9 发布模块优化完成)
|
||||
|
||||
## 📖 快速导航
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| [时间线](#-时间线) | 开发历程 |
|
||||
|
||||
**相关文档**:
|
||||
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day7)
|
||||
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day9)
|
||||
- [部署指南](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
|
||||
|
||||
---
|
||||
@@ -102,6 +102,23 @@
|
||||
- [x] QR登录超时修复 (Stealth模式、多选择器fallback)
|
||||
- [x] 文档规则优化 (智能修改标准、工具使用规范)
|
||||
|
||||
### 阶段十二:用户体验优化 (Day 8)
|
||||
- [x] 文件名保留 (时间戳前缀 + 原始名称)
|
||||
- [x] 视频持久化 (从文件系统读取历史)
|
||||
- [x] 历史视频列表组件
|
||||
- [x] 素材/视频删除功能
|
||||
- [x] 登出功能 (Logout API + 前端按钮)
|
||||
- [x] 前端 SWR 轮询优化
|
||||
- [x] QR 登录状态检测修复
|
||||
|
||||
### 阶段十三:发布模块优化 (Day 9)
|
||||
- [x] B站/抖音发布验证通过
|
||||
- [x] 资源清理保障 (try-finally)
|
||||
- [x] 超时保护 (消除无限循环)
|
||||
- [x] 小红书 headless 模式修复
|
||||
- [x] API 输入验证
|
||||
- [x] 完整类型提示
|
||||
|
||||
---
|
||||
|
||||
## 🛤️ 后续规划
|
||||
@@ -109,10 +126,10 @@
|
||||
### 🔴 优先待办
|
||||
- [x] 视频合成最终验证 (MP4生成) ✅ Day 4 完成
|
||||
- [x] 端到端流程完整测试 ✅ Day 4 完成
|
||||
- [ ] 社交媒体发布测试 (B站/抖音已登录)
|
||||
- [x] 社交媒体发布测试 ✅ Day 9 完成 (B站/抖音登录+发布)
|
||||
|
||||
### 🟠 功能完善
|
||||
- [ ] 定时发布功能
|
||||
- [x] 定时发布功能 ✅ Day 7 完成
|
||||
- [ ] 批量视频生成
|
||||
- [ ] 字幕样式编辑器
|
||||
|
||||
@@ -139,7 +156,7 @@
|
||||
| TTS 配音 | 100% | ✅ 完成 |
|
||||
| 视频合成 | 100% | ✅ 完成 |
|
||||
| 唇形同步 | 100% | ✅ LatentSync 1.6 升级完成 |
|
||||
| 社交发布 | 100% | ✅ 完成 (待验证) |
|
||||
| 社交发布 | 100% | ✅ Day 9 验证通过 |
|
||||
| 服务器部署 | 100% | ✅ 完成 |
|
||||
|
||||
---
|
||||
@@ -224,5 +241,18 @@ Day 7: 社交媒体发布完善 ✅ 完成
|
||||
- 多平台发布 (B站/抖音/小红书)
|
||||
- UI 一致性优化
|
||||
- 文档规则体系优化
|
||||
|
||||
Day 8: 用户体验优化 ✅ 完成
|
||||
- 文件名保留 (时间戳前缀)
|
||||
- 视频持久化 (历史视频API)
|
||||
- 历史视频列表组件
|
||||
- 素材/视频删除功能
|
||||
|
||||
Day 9: 发布模块优化 ✅ 完成
|
||||
- B站/抖音登录+发布验证通过
|
||||
- 资源清理保障 (try-finally)
|
||||
- 超时保护 (消除无限循环)
|
||||
- 小红书 headless 模式修复
|
||||
- 完整类型提示
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 🎬 **唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Diffusion 模型
|
||||
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
|
||||
- 📱 **一键发布** - Playwright 自动发布到抖音、小红书、B站等
|
||||
- 📱 **全自动发布** - 扫码登录 + Cookie持久化,支持多平台(B站/抖音/小红书)定时发布
|
||||
- 🖥️ **Web UI** - Next.js 现代化界面
|
||||
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)
|
||||
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from app.core.config import settings
|
||||
import shutil
|
||||
import uuid
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名,移除不安全字符"""
|
||||
# 移除路径分隔符和特殊字符
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# 限制长度
|
||||
if len(safe_name) > 100:
|
||||
ext = Path(safe_name).suffix
|
||||
safe_name = safe_name[:100 - len(ext)] + ext
|
||||
return safe_name
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def upload_material(file: UploadFile = File(...)):
|
||||
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
|
||||
raise HTTPException(400, "Invalid format")
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
ext = Path(file.filename).suffix
|
||||
save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}"
|
||||
# 使用时间戳+原始文件名(保留原始名称,避免冲突)
|
||||
timestamp = int(time.time())
|
||||
safe_name = sanitize_filename(file.filename)
|
||||
save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}"
|
||||
|
||||
# Save file
|
||||
with open(save_path, "wb") as buffer:
|
||||
@@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)):
|
||||
|
||||
# Calculate size
|
||||
size_mb = save_path.stat().st_size / (1024 * 1024)
|
||||
|
||||
# 提取显示名称(去掉时间戳前缀)
|
||||
display_name = safe_name
|
||||
|
||||
return {
|
||||
"id": file_id,
|
||||
"name": file.filename,
|
||||
"path": f"uploads/materials/{file_id}{ext}",
|
||||
"id": save_path.stem,
|
||||
"name": display_name,
|
||||
"path": f"uploads/materials/{save_path.name}",
|
||||
"size_mb": size_mb,
|
||||
"type": "video"
|
||||
}
|
||||
@@ -38,9 +55,16 @@ async def list_materials():
|
||||
for f in materials_dir.glob("*"):
|
||||
try:
|
||||
stat = f.stat()
|
||||
# 提取显示名称:去掉时间戳前缀 (格式: {timestamp}_{原始文件名})
|
||||
display_name = f.name
|
||||
if '_' in f.name:
|
||||
parts = f.name.split('_', 1)
|
||||
if parts[0].isdigit():
|
||||
display_name = parts[1] # 原始文件名
|
||||
|
||||
files.append({
|
||||
"id": f.stem,
|
||||
"name": f.name,
|
||||
"name": display_name,
|
||||
"path": f"uploads/materials/{f.name}",
|
||||
"size_mb": stat.st_size / (1024 * 1024),
|
||||
"type": "video",
|
||||
@@ -51,3 +75,26 @@ async def list_materials():
|
||||
# Sort by creation time desc
|
||||
files.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
||||
return {"materials": files}
|
||||
|
||||
|
||||
@router.delete("/{material_id}")
|
||||
async def delete_material(material_id: str):
|
||||
"""删除素材文件"""
|
||||
materials_dir = settings.UPLOAD_DIR / "materials"
|
||||
|
||||
# 查找匹配的文件(ID 是文件名不含扩展名)
|
||||
found = None
|
||||
for f in materials_dir.glob("*"):
|
||||
if f.stem == material_id:
|
||||
found = f
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(404, "Material not found")
|
||||
|
||||
try:
|
||||
found.unlink()
|
||||
return {"success": True, "message": "素材已删除"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"删除失败: {str(e)}")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ router = APIRouter()
|
||||
publish_service = PublishService()
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
"""Video publish request model"""
|
||||
video_path: str
|
||||
platform: str
|
||||
title: str
|
||||
@@ -20,13 +21,25 @@ class PublishRequest(BaseModel):
|
||||
publish_time: Optional[datetime] = None
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
"""Video publish response model"""
|
||||
success: bool
|
||||
message: str
|
||||
platform: str
|
||||
url: Optional[str] = None
|
||||
|
||||
# Supported platforms for validation
|
||||
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
|
||||
|
||||
@router.post("/", response_model=PublishResponse)
|
||||
async def publish_video(request: PublishRequest, background_tasks: BackgroundTasks):
|
||||
"""发布视频到指定平台"""
|
||||
# Validate platform
|
||||
if request.platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的平台: {request.platform}。支持的平台: {', '.join(SUPPORTED_PLATFORMS)}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await publish_service.publish(
|
||||
video_path=request.video_path,
|
||||
@@ -56,22 +69,32 @@ async def list_accounts():
|
||||
|
||||
@router.post("/login/{platform}")
|
||||
async def login_platform(platform: str):
|
||||
"""触发平台QR码登录"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
result = await publish_service.login(platform)
|
||||
if result.get("success"):
|
||||
return result
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
||||
|
||||
@router.post("/logout/{platform}")
|
||||
async def logout_platform(platform: str):
|
||||
"""注销平台登录"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
result = publish_service.logout(platform)
|
||||
return result
|
||||
|
||||
@router.get("/login/status/{platform}")
|
||||
async def get_login_status(platform: str):
|
||||
"""检查登录状态"""
|
||||
# 这里简化处理,实际应该维护一个登录会话字典
|
||||
cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json"
|
||||
"""检查登录状态 (优先检查活跃的扫码会话)"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
if cookie_file.exists():
|
||||
return {"success": True, "message": "已登录"}
|
||||
else:
|
||||
return {"success": False, "message": "未登录"}
|
||||
return publish_service.get_login_session_status(platform)
|
||||
|
||||
@router.post("/cookies/save/{platform}")
|
||||
async def save_platform_cookie(platform: str, cookie_data: dict):
|
||||
@@ -82,7 +105,13 @@ async def save_platform_cookie(platform: str, cookie_data: dict):
|
||||
platform: 平台ID
|
||||
cookie_data: {"cookie_string": "document.cookie的内容"}
|
||||
"""
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
|
||||
cookie_string = cookie_data.get("cookie_string", "")
|
||||
if not cookie_string:
|
||||
raise HTTPException(status_code=400, detail="cookie_string 不能为空")
|
||||
|
||||
result = await publish_service.save_cookie_string(platform, cookie_string)
|
||||
|
||||
if result.get("success"):
|
||||
|
||||
@@ -141,3 +141,58 @@ async def lipsync_health():
|
||||
"""获取 LipSync 服务健康状态"""
|
||||
lipsync = _get_lipsync_service()
|
||||
return await lipsync.check_health()
|
||||
|
||||
|
||||
@router.get("/generated")
|
||||
async def list_generated_videos():
|
||||
"""从文件系统读取生成的视频列表(持久化)"""
|
||||
output_dir = settings.OUTPUT_DIR
|
||||
videos = []
|
||||
|
||||
if output_dir.exists():
|
||||
for f in output_dir.glob("*_output.mp4"):
|
||||
try:
|
||||
stat = f.stat()
|
||||
videos.append({
|
||||
"id": f.stem,
|
||||
"name": f.name,
|
||||
"path": f"/outputs/{f.name}",
|
||||
"size_mb": stat.st_size / (1024 * 1024),
|
||||
"created_at": stat.st_ctime
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by creation time desc (newest first)
|
||||
videos.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
||||
return {"videos": videos}
|
||||
|
||||
|
||||
@router.delete("/generated/{video_id}")
|
||||
async def delete_generated_video(video_id: str):
|
||||
"""删除生成的视频"""
|
||||
output_dir = settings.OUTPUT_DIR
|
||||
|
||||
# 查找匹配的文件
|
||||
found = None
|
||||
for f in output_dir.glob("*.mp4"):
|
||||
if f.stem == video_id:
|
||||
found = f
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(404, "Video not found")
|
||||
|
||||
try:
|
||||
found.unlink()
|
||||
# 同时删除相关的临时文件(如果存在)
|
||||
task_id = video_id.replace("_output", "")
|
||||
for suffix in ["_audio.mp3", "_lipsync.mp4"]:
|
||||
temp_file = output_dir / f"{task_id}{suffix}"
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
return {"success": True, "message": "视频已删除"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"删除失败: {str(e)}")
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
发布服务 (基于 social-auto-upload 架构)
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from loguru import logger
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -16,19 +17,22 @@ from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
class PublishService:
|
||||
"""Social media publishing service"""
|
||||
|
||||
PLATFORMS = {
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame"},
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/"},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/"},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/"},
|
||||
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/"},
|
||||
# 支持的平台配置
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
|
||||
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/", "enabled": False},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self.cookies_dir = settings.BASE_DIR / "cookies"
|
||||
self.cookies_dir.mkdir(exist_ok=True)
|
||||
# 存储活跃的登录会话,用于跟踪登录状态
|
||||
self.active_login_sessions: Dict[str, Any] = {}
|
||||
|
||||
def get_accounts(self):
|
||||
def get_accounts(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of platform accounts with login status"""
|
||||
accounts = []
|
||||
for pid, pinfo in self.PLATFORMS.items():
|
||||
@@ -37,7 +41,7 @@ class PublishService:
|
||||
"platform": pid,
|
||||
"name": pinfo["name"],
|
||||
"logged_in": cookie_file.exists(),
|
||||
"enabled": True
|
||||
"enabled": pinfo.get("enabled", True)
|
||||
})
|
||||
return accounts
|
||||
|
||||
@@ -49,8 +53,8 @@ class PublishService:
|
||||
tags: List[str],
|
||||
description: str = "",
|
||||
publish_time: Optional[datetime] = None,
|
||||
**kwargs
|
||||
):
|
||||
**kwargs: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Publish video to specified platform
|
||||
|
||||
@@ -87,7 +91,7 @@ class PublishService:
|
||||
if platform == "bilibili":
|
||||
uploader = BilibiliUploader(
|
||||
title=title,
|
||||
file_path=str(settings.BASE_DIR / video_path), # Convert to absolute path
|
||||
file_path=str(settings.BASE_DIR.parent / video_path), # Convert to absolute path
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
@@ -98,7 +102,7 @@ class PublishService:
|
||||
elif platform == "douyin":
|
||||
uploader = DouyinUploader(
|
||||
title=title,
|
||||
file_path=str(settings.BASE_DIR / video_path),
|
||||
file_path=str(settings.BASE_DIR.parent / video_path),
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
@@ -107,7 +111,7 @@ class PublishService:
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=str(settings.BASE_DIR / video_path),
|
||||
file_path=str(settings.BASE_DIR.parent / video_path),
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
@@ -134,7 +138,7 @@ class PublishService:
|
||||
"platform": platform
|
||||
}
|
||||
|
||||
async def login(self, platform: str):
|
||||
async def login(self, platform: str) -> Dict[str, Any]:
|
||||
"""
|
||||
启动QR码登录流程
|
||||
|
||||
@@ -150,6 +154,9 @@ class PublishService:
|
||||
# 创建QR登录服务
|
||||
qr_service = QRLoginService(platform, self.cookies_dir)
|
||||
|
||||
# 存储活跃会话
|
||||
self.active_login_sessions[platform] = qr_service
|
||||
|
||||
# 启动登录并获取二维码
|
||||
result = await qr_service.start_login()
|
||||
|
||||
@@ -161,8 +168,55 @@ class PublishService:
|
||||
"success": False,
|
||||
"message": f"登录失败: {str(e)}"
|
||||
}
|
||||
|
||||
def get_login_session_status(self, platform: str) -> Dict[str, Any]:
|
||||
"""获取活跃登录会话的状态"""
|
||||
# 1. 如果有活跃的扫码会话,优先检查它
|
||||
if platform in self.active_login_sessions:
|
||||
qr_service = self.active_login_sessions[platform]
|
||||
status = qr_service.get_login_status()
|
||||
|
||||
# 如果登录成功且Cookie已保存,清理会话
|
||||
if status["success"] and status["cookies_saved"]:
|
||||
del self.active_login_sessions[platform]
|
||||
return {"success": True, "message": "登录成功"}
|
||||
|
||||
return {"success": False, "message": "等待扫码..."}
|
||||
|
||||
# 2. 如果没有活跃会话,检查本地Cookie文件是否存在 (用于页面初始加载)
|
||||
# 注意:这无法检测Cookie是否过期,只能检测文件在不在
|
||||
# 在扫码流程中,前端应该依赖上面第1步的返回
|
||||
cookie_file = self.cookies_dir / f"{platform}_cookies.json"
|
||||
if cookie_file.exists():
|
||||
return {"success": True, "message": "已登录 (历史状态)"}
|
||||
|
||||
return {"success": False, "message": "未登录"}
|
||||
|
||||
async def save_cookie_string(self, platform: str, cookie_string: str):
|
||||
def logout(self, platform: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Logout from platform (delete cookie file)
|
||||
"""
|
||||
if platform not in self.PLATFORMS:
|
||||
return {"success": False, "message": "不支持的平台"}
|
||||
|
||||
try:
|
||||
# 1. 移除活跃会话
|
||||
if platform in self.active_login_sessions:
|
||||
del self.active_login_sessions[platform]
|
||||
|
||||
# 2. 删除Cookie文件
|
||||
cookie_file = self.cookies_dir / f"{platform}_cookies.json"
|
||||
if cookie_file.exists():
|
||||
cookie_file.unlink()
|
||||
logger.info(f"[登出] {platform} Cookie已删除")
|
||||
|
||||
return {"success": True, "message": "已注销"}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[登出] 失败: {e}")
|
||||
return {"success": False, "message": f"注销失败: {str(e)}"}
|
||||
|
||||
async def save_cookie_string(self, platform: str, cookie_string: str) -> Dict[str, Any]:
|
||||
"""
|
||||
保存从客户端浏览器提取的Cookie字符串
|
||||
|
||||
@@ -198,7 +252,6 @@ class PublishService:
|
||||
cookie_dict = bilibili_cookies
|
||||
|
||||
# 保存Cookie
|
||||
import json
|
||||
with open(account_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cookie_dict, f, indent=2)
|
||||
|
||||
|
||||
@@ -4,22 +4,30 @@ QR码自动登录服务
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from playwright.async_api import async_playwright, Page
|
||||
from loguru import logger
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from playwright.async_api import async_playwright, Page, BrowserContext, Browser, Playwright as PW
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class QRLoginService:
|
||||
"""QR码登录服务"""
|
||||
|
||||
def __init__(self, platform: str, cookies_dir: Path):
|
||||
# 登录监控超时 (秒)
|
||||
LOGIN_TIMEOUT = 120
|
||||
|
||||
def __init__(self, platform: str, cookies_dir: Path) -> None:
|
||||
self.platform = platform
|
||||
self.cookies_dir = cookies_dir
|
||||
self.qr_code_image = None
|
||||
self.login_success = False
|
||||
self.cookies_data = None
|
||||
self.qr_code_image: Optional[str] = None
|
||||
self.login_success: bool = False
|
||||
self.cookies_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Playwright 资源 (手动管理生命周期)
|
||||
self.playwright: Optional[PW] = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
|
||||
# 每个平台使用多个选择器 (使用逗号分隔,Playwright会同时等待它们)
|
||||
self.platform_configs = {
|
||||
@@ -56,7 +64,7 @@ class QRLoginService:
|
||||
}
|
||||
}
|
||||
|
||||
async def start_login(self):
|
||||
async def start_login(self) -> Dict[str, Any]:
|
||||
"""
|
||||
启动登录流程
|
||||
|
||||
@@ -129,109 +137,112 @@ class QRLoginService:
|
||||
await self._cleanup()
|
||||
return {"success": False, "message": f"启动失败: {str(e)}"}
|
||||
|
||||
async def _extract_qr_code(self, page: Page, selectors: list) -> str:
|
||||
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
|
||||
"""
|
||||
提取二维码图片(并行执行 CSS策略 和 文本策略)
|
||||
提取二维码图片 (优化策略顺序)
|
||||
根据日志分析:抖音和B站使用 Text 策略成功率最高
|
||||
"""
|
||||
async def strategy_css():
|
||||
qr_element = None
|
||||
|
||||
# 针对抖音和B站:优先使用 Text 策略 (成功率最高,速度最快)
|
||||
if self.platform in ("douyin", "bilibili"):
|
||||
# 尝试最多2次 (首次 + 1次重试)
|
||||
for attempt in range(2):
|
||||
if attempt > 0:
|
||||
logger.info(f"[{self.platform}] 等待页面加载后重试...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 策略1: Text (优先,成功率最高)
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] Text策略截图失败: {e}")
|
||||
qr_element = None
|
||||
|
||||
# 策略2: CSS (备用)
|
||||
if not qr_element:
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
|
||||
# 增加超时到5秒,抖音页面加载较慢
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
|
||||
screenshot = await el.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
|
||||
|
||||
# 如果已成功,退出循环
|
||||
if qr_element:
|
||||
break
|
||||
else:
|
||||
# 其他平台 (小红书等):保持原顺序 CSS -> Text
|
||||
# 策略1: CSS 选择器
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
|
||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
if el:
|
||||
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
|
||||
return el
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def strategy_text():
|
||||
# 扩展支持 Bilibili 和 Douyin
|
||||
if self.platform not in ["bilibili", "douyin"]: return None
|
||||
try:
|
||||
logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...")
|
||||
# 关键词列表
|
||||
keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"]
|
||||
scan_text = None
|
||||
|
||||
# 遍历尝试关键词 (带等待)
|
||||
for kw in keywords:
|
||||
try:
|
||||
t = page.get_by_text(kw, exact=False).first
|
||||
# 稍微等待一下文字渲染
|
||||
await t.wait_for(state="visible", timeout=2000)
|
||||
scan_text = t
|
||||
logger.debug(f"[{self.platform}] 找到关键词: {kw}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if scan_text:
|
||||
# 尝试定位周边的图片
|
||||
parent_locator = scan_text
|
||||
# 向上查找5层(扩大范围)
|
||||
for _ in range(5):
|
||||
parent_locator = parent_locator.locator("..")
|
||||
|
||||
# 找图片
|
||||
img = parent_locator.locator("img").first
|
||||
if await img.is_visible():
|
||||
# 过滤掉头像等小图标,确保尺寸足够大
|
||||
bbox = await img.bounding_box()
|
||||
if bbox and bbox['width'] > 100:
|
||||
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Img)")
|
||||
return img
|
||||
|
||||
# 找Canvas
|
||||
canvas = parent_locator.locator("canvas").first
|
||||
if await canvas.is_visible():
|
||||
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Canvas)")
|
||||
return canvas
|
||||
|
||||
qr_element = el
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略2异常: {e}")
|
||||
return None
|
||||
|
||||
# 并行执行两个策略,谁先找到算谁的
|
||||
tasks = [
|
||||
asyncio.create_task(strategy_css()),
|
||||
asyncio.create_task(strategy_text())
|
||||
]
|
||||
|
||||
qr_element = None
|
||||
pending = set(tasks)
|
||||
|
||||
while pending:
|
||||
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
||||
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
|
||||
|
||||
for task in done:
|
||||
result = await task
|
||||
if result:
|
||||
qr_element = result
|
||||
break
|
||||
# 策略2: Text
|
||||
if not qr_element:
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
|
||||
# 如果找到元素,截图返回
|
||||
if qr_element:
|
||||
break
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 截图失败: {e}")
|
||||
|
||||
# 取消剩下的任务 (如果找到了)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
if qr_element:
|
||||
try:
|
||||
screenshot = await qr_element.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 截图失败: {e}")
|
||||
|
||||
# 失败处理
|
||||
logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图")
|
||||
# 所有策略失败
|
||||
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
|
||||
|
||||
# 保存调试截图
|
||||
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
|
||||
debug_dir.mkdir(exist_ok=True)
|
||||
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
|
||||
|
||||
screenshot = await page.screenshot()
|
||||
return base64.b64encode(screenshot).decode()
|
||||
return None
|
||||
|
||||
async def _try_text_strategy(self, page: Page) -> Optional[Any]:
|
||||
"""基于文本查找二维码图片"""
|
||||
try:
|
||||
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
|
||||
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP", "使用APP扫码"]
|
||||
|
||||
for kw in keywords:
|
||||
try:
|
||||
text_el = page.get_by_text(kw, exact=False).first
|
||||
await text_el.wait_for(state="visible", timeout=2000)
|
||||
|
||||
# 向上查找图片
|
||||
parent = text_el
|
||||
for _ in range(5):
|
||||
parent = parent.locator("..")
|
||||
imgs = parent.locator("img")
|
||||
|
||||
for i in range(await imgs.count()):
|
||||
img = imgs.nth(i)
|
||||
if await img.is_visible():
|
||||
bbox = await img.bounding_box()
|
||||
if bbox and bbox['width'] > 100:
|
||||
logger.info(f"[{self.platform}] 策略Text: 成功")
|
||||
return img
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.platform}] 策略Text 失败: {e}")
|
||||
return None
|
||||
|
||||
async def _monitor_login_status(self, page: Page, success_url: str):
|
||||
"""监控登录状态"""
|
||||
@@ -240,7 +251,7 @@ class QRLoginService:
|
||||
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
|
||||
target_cookie = key_cookies.get(self.platform, "")
|
||||
|
||||
for i in range(120):
|
||||
for i in range(self.LOGIN_TIMEOUT):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
@@ -275,37 +286,57 @@ class QRLoginService:
|
||||
finally:
|
||||
await self._cleanup()
|
||||
|
||||
async def _cleanup(self):
|
||||
async def _cleanup(self) -> None:
|
||||
"""清理资源"""
|
||||
if hasattr(self, 'context') and self.context:
|
||||
try: await self.context.close()
|
||||
except: pass
|
||||
if hasattr(self, 'browser') and self.browser:
|
||||
try: await self.browser.close()
|
||||
except: pass
|
||||
if hasattr(self, 'playwright') and self.playwright:
|
||||
try: await self.playwright.stop()
|
||||
except: pass
|
||||
if self.context:
|
||||
try:
|
||||
await self.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.context = None
|
||||
if self.browser:
|
||||
try:
|
||||
await self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.browser = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
async def _save_cookies(self, cookies: list):
|
||||
async def _save_cookies(self, cookies: List[Dict[str, Any]]) -> None:
|
||||
"""保存Cookie到文件"""
|
||||
try:
|
||||
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
|
||||
cookie_dict = {c['name']: c['value'] for c in cookies}
|
||||
|
||||
if self.platform == "bilibili":
|
||||
# Bilibili 使用简单格式 (biliup库需要)
|
||||
cookie_dict = {c['name']: c['value'] for c in cookies}
|
||||
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
|
||||
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cookie_dict, f, indent=2)
|
||||
self.cookies_data = cookie_dict
|
||||
else:
|
||||
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
|
||||
# 这样可以直接用 browser.new_context(storage_state=file)
|
||||
storage_state = {
|
||||
"cookies": cookies,
|
||||
"origins": []
|
||||
}
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(storage_state, f, indent=2)
|
||||
self.cookies_data = storage_state
|
||||
|
||||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cookie_dict, f, indent=2)
|
||||
|
||||
self.cookies_data = cookie_dict
|
||||
logger.success(f"[{self.platform}] Cookie已保存")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
|
||||
|
||||
def get_login_status(self):
|
||||
def get_login_status(self) -> Dict[str, Any]:
|
||||
"""获取登录状态"""
|
||||
return {
|
||||
"success": self.login_success,
|
||||
|
||||
@@ -3,7 +3,7 @@ Base uploader class for all social media platforms
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class BaseUploader(ABC):
|
||||
self.description = description
|
||||
|
||||
@abstractmethod
|
||||
async def main(self):
|
||||
async def main(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Main upload method - must be implemented by subclasses
|
||||
|
||||
@@ -50,7 +50,7 @@ class BaseUploader(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def _get_timestamp(self, dt):
|
||||
def _get_timestamp(self, dt: Union[datetime, int]) -> int:
|
||||
"""
|
||||
Convert datetime to Unix timestamp
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
Bilibili uploader using biliup library
|
||||
"""
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
try:
|
||||
from biliup.plugins.bili_webup import BiliBili, Data
|
||||
@@ -15,6 +17,9 @@ except ImportError:
|
||||
from loguru import logger
|
||||
from .base_uploader import BaseUploader
|
||||
|
||||
# Thread pool for running sync biliup code
|
||||
_executor = ThreadPoolExecutor(max_workers=2)
|
||||
|
||||
|
||||
class BilibiliUploader(BaseUploader):
|
||||
"""Bilibili video uploader using biliup library"""
|
||||
@@ -46,13 +51,19 @@ class BilibiliUploader(BaseUploader):
|
||||
"biliup library not installed. Please run: pip install biliup"
|
||||
)
|
||||
|
||||
async def main(self):
|
||||
async def main(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload video to Bilibili
|
||||
|
||||
Returns:
|
||||
dict: Upload result
|
||||
"""
|
||||
# Run sync upload in thread pool to avoid asyncio.run() conflict
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(_executor, self._upload_sync)
|
||||
|
||||
def _upload_sync(self) -> Dict[str, Any]:
|
||||
"""Synchronous upload logic (runs in thread pool)"""
|
||||
try:
|
||||
# 1. Load cookie data
|
||||
if not self.account_file or not Path(self.account_file).exists():
|
||||
@@ -66,6 +77,22 @@ class BilibiliUploader(BaseUploader):
|
||||
with open(self.account_file, 'r', encoding='utf-8') as f:
|
||||
cookie_data = json.load(f)
|
||||
|
||||
# Convert simple cookie format to biliup format if needed
|
||||
if 'cookie_info' not in cookie_data and 'SESSDATA' in cookie_data:
|
||||
# Transform to biliup expected format
|
||||
cookie_data = {
|
||||
'cookie_info': {
|
||||
'cookies': [
|
||||
{'name': k, 'value': v} for k, v in cookie_data.items()
|
||||
]
|
||||
},
|
||||
'token_info': {
|
||||
'access_token': cookie_data.get('access_token', ''),
|
||||
'refresh_token': cookie_data.get('refresh_token', '')
|
||||
}
|
||||
}
|
||||
logger.info("[B站] Cookie格式已转换")
|
||||
|
||||
# 2. Prepare video data
|
||||
data = Data()
|
||||
data.copyright = self.copyright
|
||||
@@ -97,17 +124,39 @@ class BilibiliUploader(BaseUploader):
|
||||
# Submit
|
||||
ret = bili.submit()
|
||||
|
||||
# Debug: log full response
|
||||
logger.debug(f"[B站] API响应: {ret}")
|
||||
|
||||
if ret.get('code') == 0:
|
||||
bvid = ret.get('bvid', '')
|
||||
logger.success(f"[B站] 上传成功: {bvid}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "上传成功" if data.dtime == 0 else "已设置定时发布",
|
||||
"url": f"https://www.bilibili.com/video/{bvid}" if bvid else None
|
||||
}
|
||||
# Try multiple keys for bvid (API may vary)
|
||||
bvid = ret.get('data', {}).get('bvid') or ret.get('bvid', '')
|
||||
aid = ret.get('data', {}).get('aid') or ret.get('aid', '')
|
||||
|
||||
if bvid:
|
||||
logger.success(f"[B站] 上传成功: {bvid}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布成功,待审核" if data.dtime == 0 else "已设置定时发布",
|
||||
"url": f"https://www.bilibili.com/video/{bvid}"
|
||||
}
|
||||
elif aid:
|
||||
logger.success(f"[B站] 上传成功: av{aid}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布成功,待审核" if data.dtime == 0 else "已设置定时发布",
|
||||
"url": f"https://www.bilibili.com/video/av{aid}"
|
||||
}
|
||||
else:
|
||||
# No bvid/aid but code=0, still consider success
|
||||
logger.warning(f"[B站] 上传返回code=0但无bvid/aid: {ret}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布成功,待审核",
|
||||
"url": None
|
||||
}
|
||||
else:
|
||||
error_msg = ret.get('message', '未知错误')
|
||||
logger.error(f"[B站] 上传失败: {error_msg}")
|
||||
logger.error(f"[B站] 上传失败: {error_msg} (完整响应: {ret})")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"上传失败: {error_msg}",
|
||||
|
||||
@@ -1,169 +1,585 @@
|
||||
"""
|
||||
Douyin (抖音) uploader using Playwright
|
||||
Based on social-auto-upload implementation
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import asyncio
|
||||
|
||||
from playwright.async_api import Playwright, async_playwright
|
||||
from loguru import logger
|
||||
|
||||
from .base_uploader import BaseUploader
|
||||
from .cookie_utils import set_init_script
|
||||
|
||||
|
||||
class DouyinUploader(BaseUploader):
|
||||
"""Douyin video uploader using Playwright"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
file_path: str,
|
||||
tags: List[str],
|
||||
publish_date: Optional[datetime] = None,
|
||||
account_file: Optional[str] = None,
|
||||
description: str = ""
|
||||
):
|
||||
super().__init__(title, file_path, tags, publish_date, account_file, description)
|
||||
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
|
||||
|
||||
async def set_schedule_time(self, page, publish_date):
|
||||
"""Set scheduled publish time"""
|
||||
try:
|
||||
# Click "定时发布" radio button
|
||||
label_element = page.locator("[class^='radio']:has-text('定时发布')")
|
||||
await label_element.click()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Format time
|
||||
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Fill datetime input
|
||||
await page.locator('.semi-input[placeholder="日期和时间"]').click()
|
||||
await page.keyboard.press("Control+KeyA")
|
||||
await page.keyboard.type(str(publish_date_hour))
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[抖音] 设置定时发布失败: {e}")
|
||||
|
||||
async def upload(self, playwright: Playwright):
|
||||
"""Main upload logic"""
|
||||
try:
|
||||
# Launch browser
|
||||
browser = await playwright.chromium.launch(headless=False)
|
||||
context = await browser.new_context(storage_state=self.account_file)
|
||||
context = await set_init_script(context)
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
# Go to upload page
|
||||
await page.goto(self.upload_url)
|
||||
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
|
||||
|
||||
# Upload video file
|
||||
await page.set_input_files("input[type='file']", str(self.file_path))
|
||||
|
||||
# Wait for redirect to publish page
|
||||
while True:
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page",
|
||||
timeout=3000
|
||||
)
|
||||
logger.info("[抖音] 成功进入发布页面")
|
||||
break
|
||||
except:
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
|
||||
timeout=3000
|
||||
)
|
||||
logger.info("[抖音] 成功进入发布页面 (版本2)")
|
||||
break
|
||||
except:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Fill title
|
||||
await asyncio.sleep(1)
|
||||
logger.info("[抖音] 正在填充标题和话题...")
|
||||
|
||||
title_container = page.get_by_text('作品描述').locator("..").locator("..").locator(
|
||||
"xpath=following-sibling::div[1]").locator("input")
|
||||
|
||||
if await title_container.count():
|
||||
await title_container.fill(self.title[:30])
|
||||
|
||||
# Add tags
|
||||
css_selector = ".zone-container"
|
||||
for tag in self.tags:
|
||||
await page.type(css_selector, "#" + tag)
|
||||
await page.press(css_selector, "Space")
|
||||
|
||||
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
|
||||
|
||||
# Wait for upload to complete
|
||||
while True:
|
||||
try:
|
||||
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
|
||||
if number > 0:
|
||||
logger.success("[抖音] 视频上传完毕")
|
||||
break
|
||||
else:
|
||||
logger.info("[抖音] 正在上传视频中...")
|
||||
await asyncio.sleep(2)
|
||||
except:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Set scheduled publish time if needed
|
||||
if self.publish_date != 0:
|
||||
await self.set_schedule_time(page, self.publish_date)
|
||||
|
||||
# Click publish button
|
||||
while True:
|
||||
try:
|
||||
publish_button = page.get_by_role('button', name="发布", exact=True)
|
||||
if await publish_button.count():
|
||||
await publish_button.click()
|
||||
|
||||
await page.wait_for_url(
|
||||
"https://creator.douyin.com/creator-micro/content/manage**",
|
||||
timeout=3000
|
||||
)
|
||||
logger.success("[抖音] 视频发布成功")
|
||||
break
|
||||
except:
|
||||
logger.info("[抖音] 视频正在发布中...")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Save updated cookies
|
||||
await context.storage_state(path=self.account_file)
|
||||
logger.success("[抖音] Cookie 更新完毕")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
|
||||
"url": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[抖音] 上传失败: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"上传失败: {str(e)}",
|
||||
"url": None
|
||||
}
|
||||
|
||||
async def main(self):
|
||||
"""Execute upload"""
|
||||
async with async_playwright() as playwright:
|
||||
return await self.upload(playwright)
|
||||
"""
|
||||
Douyin (抖音) uploader using Playwright
|
||||
Based on social-auto-upload implementation
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from playwright.async_api import Playwright, async_playwright
|
||||
from loguru import logger
|
||||
|
||||
from .base_uploader import BaseUploader
|
||||
from .cookie_utils import set_init_script
|
||||
|
||||
|
||||
class DouyinUploader(BaseUploader):
|
||||
"""Douyin video uploader using Playwright"""
|
||||
|
||||
# 超时配置 (秒)
|
||||
UPLOAD_TIMEOUT = 300 # 视频上传超时
|
||||
PUBLISH_TIMEOUT = 180 # 发布检测超时
|
||||
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
|
||||
POLL_INTERVAL = 2 # 轮询间隔
|
||||
MAX_CLICK_RETRIES = 3 # 按钮点击重试次数
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
file_path: str,
|
||||
tags: List[str],
|
||||
publish_date: Optional[datetime] = None,
|
||||
account_file: Optional[str] = None,
|
||||
description: str = ""
|
||||
):
|
||||
super().__init__(title, file_path, tags, publish_date, account_file, description)
|
||||
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
|
||||
|
||||
async def _is_text_visible(self, page, text: str, exact: bool = False) -> bool:
|
||||
try:
|
||||
return await page.get_by_text(text, exact=exact).first.is_visible()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _first_visible_locator(self, locator, timeout: int = 1000):
|
||||
try:
|
||||
if await locator.count() == 0:
|
||||
return None
|
||||
candidate = locator.first
|
||||
if await candidate.is_visible(timeout=timeout):
|
||||
return candidate
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _wait_for_publish_result(self, page, max_wait_time: int = 180):
|
||||
success_texts = ["发布成功", "作品已发布", "再发一条", "查看作品", "审核中", "待审核"]
|
||||
weak_texts = ["发布完成"]
|
||||
failure_texts = ["发布失败", "发布异常", "发布出错", "请完善", "请补充", "请先上传"]
|
||||
start_time = time.time()
|
||||
poll_interval = 2
|
||||
weak_reason = None
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
if page.is_closed():
|
||||
return False, "页面已关闭", False
|
||||
|
||||
current_url = page.url
|
||||
if "content/manage" in current_url:
|
||||
return True, f"已跳转到管理页面 (URL: {current_url})", False
|
||||
|
||||
for text in success_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return True, f"检测到成功提示: {text}", False
|
||||
|
||||
for text in failure_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
return False, f"检测到失败提示: {text}", False
|
||||
|
||||
for text in weak_texts:
|
||||
if await self._is_text_visible(page, text, exact=False):
|
||||
weak_reason = text
|
||||
|
||||
logger.info("[抖音] 视频正在发布中...")
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
if weak_reason:
|
||||
return False, f"检测到提示: {weak_reason}", True
|
||||
|
||||
return False, "发布检测超时", True
|
||||
|
||||
async def _fill_title(self, page, title: str) -> bool:
|
||||
title_text = title[:30]
|
||||
locator_candidates = []
|
||||
|
||||
try:
|
||||
label_locator = page.get_by_text("作品描述").locator("..").locator("..").locator(
|
||||
"xpath=following-sibling::div[1]"
|
||||
).locator("textarea, input, div[contenteditable='true']")
|
||||
locator_candidates.append(label_locator)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
locator_candidates.extend([
|
||||
page.locator("textarea[placeholder*='作品描述']"),
|
||||
page.locator("textarea[placeholder*='描述']"),
|
||||
page.locator("input[placeholder*='作品描述']"),
|
||||
page.locator("input[placeholder*='描述']"),
|
||||
page.locator("div[contenteditable='true']"),
|
||||
])
|
||||
|
||||
for locator in locator_candidates:
|
||||
try:
|
||||
if await locator.count() > 0:
|
||||
target = locator.first
|
||||
await target.fill(title_text)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
async def _select_cover_if_needed(self, page) -> bool:
|
||||
try:
|
||||
cover_button = page.get_by_text("选择封面", exact=False).first
|
||||
if await cover_button.is_visible():
|
||||
await cover_button.click()
|
||||
logger.info("[抖音] 尝试选择封面")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
dialog = page.locator(
|
||||
"div.dy-creator-content-modal-wrap, div[role='dialog'], "
|
||||
"div[class*='modal'], div[class*='dialog']"
|
||||
).last
|
||||
scopes = [dialog] if await dialog.count() > 0 else [page]
|
||||
|
||||
switched = False
|
||||
for scope in scopes:
|
||||
for selector in [
|
||||
"button:has-text('设置横封面')",
|
||||
"div:has-text('设置横封面')",
|
||||
"span:has-text('设置横封面')",
|
||||
]:
|
||||
try:
|
||||
button = await self._first_visible_locator(scope.locator(selector))
|
||||
if button:
|
||||
await button.click()
|
||||
logger.info("[抖音] 已切换到横封面设置")
|
||||
await asyncio.sleep(0.5)
|
||||
switched = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if switched:
|
||||
break
|
||||
|
||||
selected = False
|
||||
for scope in scopes:
|
||||
for selector in [
|
||||
"div[class*='cover'] img",
|
||||
"div[class*='cover']",
|
||||
"div[class*='frame'] img",
|
||||
"div[class*='frame']",
|
||||
"div[class*='preset']",
|
||||
"img",
|
||||
]:
|
||||
try:
|
||||
candidate = await self._first_visible_locator(scope.locator(selector))
|
||||
if candidate:
|
||||
await candidate.click()
|
||||
logger.info("[抖音] 已选择封面帧")
|
||||
selected = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if selected:
|
||||
break
|
||||
|
||||
confirm_selectors = [
|
||||
"button:has-text('完成')",
|
||||
"button:has-text('确定')",
|
||||
"button:has-text('保存')",
|
||||
"button:has-text('确认')",
|
||||
]
|
||||
for selector in confirm_selectors:
|
||||
try:
|
||||
button = await self._first_visible_locator(page.locator(selector))
|
||||
if button:
|
||||
if not await button.is_enabled():
|
||||
for _ in range(8):
|
||||
if await button.is_enabled():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
await button.click()
|
||||
logger.info(f"[抖音] 封面已确认: {selector}")
|
||||
await asyncio.sleep(0.5)
|
||||
if await dialog.count() > 0:
|
||||
try:
|
||||
await dialog.wait_for(state="hidden", timeout=5000)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return selected
|
||||
except Exception as e:
|
||||
logger.warning(f"[抖音] 选择封面失败: {e}")
|
||||
return False
|
||||
|
||||
async def _click_publish_confirm_modal(self, page):
|
||||
confirm_selectors = [
|
||||
"button:has-text('确认发布')",
|
||||
"button:has-text('继续发布')",
|
||||
"button:has-text('确定发布')",
|
||||
"button:has-text('发布确认')",
|
||||
]
|
||||
for selector in confirm_selectors:
|
||||
try:
|
||||
button = page.locator(selector).first
|
||||
if await button.is_visible():
|
||||
await button.click()
|
||||
logger.info(f"[抖音] 点击了发布确认按钮: {selector}")
|
||||
await asyncio.sleep(1)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
async def _dismiss_blocking_modal(self, page) -> bool:
|
||||
modal_locator = page.locator(
|
||||
"div.dy-creator-content-modal-wrap, div[role='dialog'], "
|
||||
"div[class*='modal'], div[class*='dialog']"
|
||||
)
|
||||
try:
|
||||
count = await modal_locator.count()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if count == 0:
|
||||
return False
|
||||
|
||||
button_texts = [
|
||||
"我知道了",
|
||||
"知道了",
|
||||
"确定",
|
||||
"继续",
|
||||
"继续发布",
|
||||
"确认",
|
||||
"同意并继续",
|
||||
"完成",
|
||||
"好的",
|
||||
"明白了",
|
||||
]
|
||||
close_selectors = [
|
||||
"button[class*='close']",
|
||||
"span[class*='close']",
|
||||
"i[class*='close']",
|
||||
]
|
||||
|
||||
for index in range(count):
|
||||
modal = modal_locator.nth(index)
|
||||
try:
|
||||
if not await modal.is_visible():
|
||||
continue
|
||||
|
||||
for text in button_texts:
|
||||
try:
|
||||
button = modal.get_by_role("button", name=text).first
|
||||
if await button.is_visible():
|
||||
await button.click()
|
||||
logger.info(f"[抖音] 关闭弹窗: {text}")
|
||||
await asyncio.sleep(0.5)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for selector in close_selectors:
|
||||
try:
|
||||
close_button = modal.locator(selector).first
|
||||
if await close_button.is_visible():
|
||||
await close_button.click()
|
||||
logger.info("[抖音] 关闭弹窗: close")
|
||||
await asyncio.sleep(0.5)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
async def _verify_publish_in_manage(self, page):
|
||||
manage_url = "https://creator.douyin.com/creator-micro/content/manage"
|
||||
try:
|
||||
await page.goto(manage_url)
|
||||
await page.wait_for_load_state("domcontentloaded")
|
||||
await asyncio.sleep(2)
|
||||
title_text = self.title[:30]
|
||||
title_locator = page.get_by_text(title_text, exact=False).first
|
||||
if await title_locator.is_visible():
|
||||
return True, "内容管理中检测到新作品"
|
||||
if await self._is_text_visible(page, "审核中", exact=False):
|
||||
return True, "内容管理显示审核中"
|
||||
except Exception as e:
|
||||
return False, f"无法验证内容管理: {e}"
|
||||
return False, "内容管理中未找到视频"
|
||||
|
||||
async def set_schedule_time(self, page, publish_date):
|
||||
"""Set scheduled publish time"""
|
||||
try:
|
||||
# Click "定时发布" radio button
|
||||
label_element = page.locator("[class^='radio']:has-text('定时发布')")
|
||||
await label_element.click()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Format time
|
||||
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Fill datetime input
|
||||
await page.locator('.semi-input[placeholder="日期和时间"]').click()
|
||||
await page.keyboard.press("Control+KeyA")
|
||||
await page.keyboard.type(str(publish_date_hour))
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[抖音] 设置定时发布失败: {e}")
|
||||
|
||||
async def upload(self, playwright: Playwright) -> dict:
|
||||
"""Main upload logic with guaranteed resource cleanup"""
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
# Launch browser in headless mode for server deployment
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context(storage_state=self.account_file)
|
||||
context = await set_init_script(context)
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
# Go to upload page
|
||||
await page.goto(self.upload_url)
|
||||
await page.wait_for_load_state('domcontentloaded')
|
||||
await asyncio.sleep(2)
|
||||
|
||||
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
|
||||
|
||||
# Check if redirected to login page (more reliable than text detection)
|
||||
current_url = page.url
|
||||
if "login" in current_url or "passport" in current_url:
|
||||
logger.error("[抖音] Cookie 已失效,被重定向到登录页")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cookie 已失效,请重新登录",
|
||||
"url": None
|
||||
}
|
||||
|
||||
# Ensure we're on the upload page
|
||||
if "content/upload" not in page.url:
|
||||
logger.info("[抖音] 当前不在上传页面,强制跳转...")
|
||||
await page.goto(self.upload_url)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Try multiple selectors for the file input (page structure varies)
|
||||
file_uploaded = False
|
||||
selectors = [
|
||||
"div[class^='container'] input", # Primary selector from SuperIPAgent
|
||||
"input[type='file']", # Fallback selector
|
||||
"div[class^='upload'] input[type='file']", # Alternative
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
try:
|
||||
logger.info(f"[抖音] 尝试选择器: {selector}")
|
||||
locator = page.locator(selector).first
|
||||
if await locator.count() > 0:
|
||||
await locator.set_input_files(str(self.file_path))
|
||||
file_uploaded = True
|
||||
logger.info(f"[抖音] 文件上传成功使用选择器: {selector}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"[抖音] 选择器 {selector} 失败: {e}")
|
||||
continue
|
||||
|
||||
if not file_uploaded:
|
||||
logger.error("[抖音] 所有选择器都失败,无法上传文件")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "无法找到上传按钮,页面可能已更新",
|
||||
"url": None
|
||||
}
|
||||
|
||||
# Wait for redirect to publish page (with timeout)
|
||||
redirect_start = time.time()
|
||||
while time.time() - redirect_start < self.PAGE_REDIRECT_TIMEOUT:
|
||||
current_url = page.url
|
||||
if "content/publish" in current_url or "content/post/video" in current_url:
|
||||
logger.info("[抖音] 成功进入发布页面")
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.error("[抖音] 等待发布页面超时")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "等待发布页面超时",
|
||||
"url": None
|
||||
}
|
||||
|
||||
# Fill title
|
||||
await asyncio.sleep(1)
|
||||
logger.info("[抖音] 正在填充标题和话题...")
|
||||
|
||||
if not await self._fill_title(page, self.title):
|
||||
logger.warning("[抖音] 未找到作品描述输入框")
|
||||
|
||||
# Add tags
|
||||
css_selector = ".zone-container"
|
||||
for tag in self.tags:
|
||||
await page.type(css_selector, "#" + tag)
|
||||
await page.press(css_selector, "Space")
|
||||
|
||||
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
|
||||
|
||||
cover_selected = await self._select_cover_if_needed(page)
|
||||
if not cover_selected:
|
||||
logger.warning("[抖音] 未确认封面选择,可能影响发布")
|
||||
|
||||
# Wait for upload to complete (with timeout)
|
||||
upload_start = time.time()
|
||||
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
|
||||
try:
|
||||
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
|
||||
if number > 0:
|
||||
logger.success("[抖音] 视频上传完毕")
|
||||
break
|
||||
else:
|
||||
logger.info("[抖音] 正在上传视频中...")
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
except Exception:
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
else:
|
||||
logger.error("[抖音] 视频上传超时")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "视频上传超时",
|
||||
"url": None
|
||||
}
|
||||
|
||||
# Set scheduled publish time if needed
|
||||
if self.publish_date != 0:
|
||||
await self.set_schedule_time(page, self.publish_date)
|
||||
|
||||
# Click publish button
|
||||
# 使用更稳健的点击逻辑
|
||||
try:
|
||||
publish_label = "定时发布" if self.publish_date != 0 else "发布"
|
||||
publish_button = page.get_by_role('button', name=publish_label, exact=True)
|
||||
# 等待按钮出现
|
||||
await publish_button.wait_for(state="visible", timeout=10000)
|
||||
if not await publish_button.is_enabled():
|
||||
logger.error("[抖音] 发布按钮不可点击,可能需要补充封面或确认信息")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "发布按钮不可点击,请检查封面/声明等必填项",
|
||||
"url": None
|
||||
}
|
||||
await asyncio.sleep(1) # 额外等待以确保可交互
|
||||
|
||||
clicked = False
|
||||
for attempt in range(self.MAX_CLICK_RETRIES):
|
||||
await self._dismiss_blocking_modal(page)
|
||||
try:
|
||||
await publish_button.click(timeout=5000)
|
||||
logger.info(f"[抖音] 点击了{publish_label}按钮")
|
||||
clicked = True
|
||||
break
|
||||
except Exception as click_error:
|
||||
logger.warning(f"[抖音] 点击发布按钮失败,重试 {attempt + 1}/{self.MAX_CLICK_RETRIES}: {click_error}")
|
||||
try:
|
||||
await page.keyboard.press("Escape")
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if not clicked:
|
||||
raise RuntimeError("点击发布按钮失败")
|
||||
except Exception as e:
|
||||
logger.error(f"[抖音] 点击发布按钮失败: {e}")
|
||||
# 尝试备用选择器
|
||||
try:
|
||||
fallback_selectors = ["button:has-text('发布')", "button:has-text('定时发布')"]
|
||||
clicked = False
|
||||
for selector in fallback_selectors:
|
||||
try:
|
||||
await page.click(selector, timeout=5000)
|
||||
logger.info(f"[抖音] 使用备用选择器点击了按钮: {selector}")
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not clicked:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "无法点击发布按钮,请检查页面状态",
|
||||
"url": None
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "无法点击发布按钮,请检查页面状态",
|
||||
"url": None
|
||||
}
|
||||
|
||||
await self._click_publish_confirm_modal(page)
|
||||
|
||||
# 4. 检测发布完成
|
||||
publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(page)
|
||||
if not publish_success and is_timeout:
|
||||
verify_success, verify_reason = await self._verify_publish_in_manage(page)
|
||||
if verify_success:
|
||||
publish_success = True
|
||||
publish_reason = verify_reason
|
||||
else:
|
||||
publish_reason = f"{publish_reason}; {verify_reason}"
|
||||
if publish_success:
|
||||
logger.success(f"[抖音] 发布成功: {publish_reason}")
|
||||
else:
|
||||
if is_timeout:
|
||||
logger.warning("[抖音] 发布检测超时,但这不一定代表失败")
|
||||
else:
|
||||
logger.warning(f"[抖音] 发布未成功: {publish_reason}")
|
||||
|
||||
# Save updated cookies
|
||||
await context.storage_state(path=self.account_file)
|
||||
logger.success("[抖音] Cookie 更新完毕")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if publish_success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布成功,待审核",
|
||||
"url": None
|
||||
}
|
||||
if is_timeout:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "发布检测超时,请到抖音后台确认",
|
||||
"url": None
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"发布失败: {publish_reason}",
|
||||
"url": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[抖音] 上传失败: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"上传失败: {str(e)}",
|
||||
"url": None
|
||||
}
|
||||
finally:
|
||||
# 确保资源释放
|
||||
if context:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
if browser:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def main(self) -> Dict[str, Any]:
|
||||
"""Execute upload"""
|
||||
async with async_playwright() as playwright:
|
||||
return await self.upload(playwright)
|
||||
|
||||
@@ -4,7 +4,7 @@ Based on social-auto-upload implementation
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
import asyncio
|
||||
|
||||
from playwright.async_api import Playwright, async_playwright
|
||||
@@ -17,6 +17,11 @@ from .cookie_utils import set_init_script
|
||||
class XiaohongshuUploader(BaseUploader):
|
||||
"""Xiaohongshu video uploader using Playwright"""
|
||||
|
||||
# 超时配置 (秒)
|
||||
UPLOAD_TIMEOUT = 300 # 视频上传超时
|
||||
PUBLISH_TIMEOUT = 120 # 发布检测超时
|
||||
POLL_INTERVAL = 1 # 轮询间隔
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
@@ -54,11 +59,13 @@ class XiaohongshuUploader(BaseUploader):
|
||||
except Exception as e:
|
||||
logger.error(f"[小红书] 设置定时发布失败: {e}")
|
||||
|
||||
async def upload(self, playwright: Playwright):
|
||||
"""Main upload logic"""
|
||||
async def upload(self, playwright: Playwright) -> dict:
|
||||
"""Main upload logic with guaranteed resource cleanup"""
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
# Launch browser
|
||||
browser = await playwright.chromium.launch(headless=False)
|
||||
# Launch browser (headless for server deployment)
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1600, "height": 900},
|
||||
storage_state=self.account_file
|
||||
@@ -74,8 +81,10 @@ class XiaohongshuUploader(BaseUploader):
|
||||
# Upload video file
|
||||
await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(str(self.file_path))
|
||||
|
||||
# Wait for upload to complete
|
||||
while True:
|
||||
# Wait for upload to complete (with timeout)
|
||||
import time
|
||||
upload_start = time.time()
|
||||
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
|
||||
try:
|
||||
upload_input = await page.wait_for_selector('input.upload-input', timeout=3000)
|
||||
preview_new = await upload_input.query_selector(
|
||||
@@ -100,11 +109,18 @@ class XiaohongshuUploader(BaseUploader):
|
||||
else:
|
||||
logger.info("[小红书] 未找到预览元素,继续等待...")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(self.POLL_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"[小红书] 检测过程: {str(e)},重新尝试...")
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.error("[小红书] 视频上传超时")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "视频上传超时",
|
||||
"url": None
|
||||
}
|
||||
|
||||
# Fill title and tags
|
||||
await asyncio.sleep(1)
|
||||
@@ -126,8 +142,9 @@ class XiaohongshuUploader(BaseUploader):
|
||||
if self.publish_date != 0:
|
||||
await self.set_schedule_time(page, self.publish_date)
|
||||
|
||||
# Click publish button
|
||||
while True:
|
||||
# Click publish button (with timeout)
|
||||
publish_start = time.time()
|
||||
while time.time() - publish_start < self.PUBLISH_TIMEOUT:
|
||||
try:
|
||||
if self.publish_date != 0:
|
||||
await page.locator('button:has-text("定时发布")').click()
|
||||
@@ -140,21 +157,21 @@ class XiaohongshuUploader(BaseUploader):
|
||||
)
|
||||
logger.success("[小红书] 视频发布成功")
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
logger.info("[小红书] 视频正在发布中...")
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.warning("[小红书] 发布检测超时,请手动确认")
|
||||
|
||||
# Save updated cookies
|
||||
await context.storage_state(path=self.account_file)
|
||||
logger.success("[小红书] Cookie 更新完毕")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
|
||||
"message": "发布成功,待审核" if self.publish_date == 0 else "已设置定时发布",
|
||||
"url": None
|
||||
}
|
||||
|
||||
@@ -165,8 +182,20 @@ class XiaohongshuUploader(BaseUploader):
|
||||
"message": f"上传失败: {str(e)}",
|
||||
"url": None
|
||||
}
|
||||
finally:
|
||||
# 确保资源释放
|
||||
if context:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
if browser:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def main(self):
|
||||
async def main(self) -> Dict[str, Any]:
|
||||
"""Execute upload"""
|
||||
async with async_playwright() as playwright:
|
||||
return await self.upload(playwright)
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# ViGent2 Frontend
|
||||
|
||||
## Getting Started
|
||||
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
|
||||
First, run the development server:
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. 视频生成 (`/`)
|
||||
- **素材管理**: 拖拽上传人物视频,实时预览。
|
||||
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **结果预览**: 生成完成后直接播放下载。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
||||
- **扫码登录**:
|
||||
- 集成后端 Playwright 生成的 QR Code。
|
||||
- 实时检测扫码状态 (Wait/Success)。
|
||||
- Cookie 自动保存与状态同步。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Next.js 14 (App Router)
|
||||
- **样式**: TailwindCSS
|
||||
- **图标**: Lucide React
|
||||
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
||||
- **API**: Fetch API (对接后端 FastAPI :8006)
|
||||
|
||||
## 🚀 开发指南
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
默认运行在 **3002** 端口 (通过 `package.json` 配置):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
# 访问: http://localhost:3002
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### 目录结构
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── page.tsx # 视频生成主页
|
||||
│ ├── publish/ # 发布管理页
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # 全局布局 (导航栏)
|
||||
├── components/ # UI 组件
|
||||
│ ├── VideoUploader.tsx # 视频上传
|
||||
│ ├── StatusBadge.tsx # 状态徽章
|
||||
│ └── ...
|
||||
└── lib/ # 工具函数
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## 🔌 后端对接
|
||||
|
||||
## Learn More
|
||||
- **Base URL**: `http://localhost:8006`
|
||||
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## 🎨 设计规范
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- **主色调**: 深紫/黑色系 (Dark Mode)
|
||||
- **交互**: 悬停微动画 (Hover Effects)
|
||||
- **响应式**: 适配桌面端大屏操作
|
||||
|
||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -2759,6 +2760,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -6027,6 +6037,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
|
||||
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
@@ -6387,6 +6410,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -23,4 +24,4 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,63 @@ body {
|
||||
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
html {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE 和 Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* IE 和 Edge */
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
display: none;
|
||||
/* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 - 深色主题 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(147, 51, 234, 0.5) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(147, 51, 234, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(147, 51, 234, 0.8);
|
||||
}
|
||||
|
||||
/* 完全隐藏滚动条 */
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 自定义 select 下拉菜单 */
|
||||
.custom-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239ca3af' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.custom-select option {
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -26,6 +26,14 @@ interface Task {
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
interface GeneratedVideo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size_mb: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
|
||||
@@ -41,6 +49,8 @@ export default function Home() {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||
|
||||
// 可选音色
|
||||
const voices = [
|
||||
@@ -51,9 +61,10 @@ export default function Home() {
|
||||
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
|
||||
];
|
||||
|
||||
// 加载素材列表
|
||||
// 加载素材列表和历史视频
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
fetchGeneratedVideos();
|
||||
}, []);
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
@@ -87,6 +98,60 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取已生成的视频列表(持久化)
|
||||
const fetchGeneratedVideos = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/videos/generated`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setGeneratedVideos(data.videos || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取历史视频失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除素材
|
||||
const deleteMaterial = async (materialId: string) => {
|
||||
if (!confirm("确定要删除这个素材吗?")) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/materials/${materialId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchMaterials();
|
||||
if (selectedMaterial === materialId) {
|
||||
setSelectedMaterial("");
|
||||
}
|
||||
} else {
|
||||
alert("删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除生成的视频
|
||||
const deleteVideo = async (videoId: string) => {
|
||||
if (!confirm("确定要删除这个视频吗?")) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/videos/generated/${videoId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchGeneratedVideos();
|
||||
if (selectedVideoId === videoId) {
|
||||
setSelectedVideoId(null);
|
||||
setGeneratedVideo(null);
|
||||
}
|
||||
} else {
|
||||
alert("删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("删除失败: " + error);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传视频
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -181,6 +246,7 @@ export default function Home() {
|
||||
if (taskData.status === "completed") {
|
||||
setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
|
||||
setIsGenerating(false);
|
||||
fetchGeneratedVideos(); // 刷新历史视频列表
|
||||
} else if (taskData.status === "failed") {
|
||||
alert("视频生成失败: " + taskData.message);
|
||||
setIsGenerating(false);
|
||||
@@ -320,21 +386,35 @@ export default function Home() {
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{materials.map((m) => (
|
||||
<button
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setSelectedMaterial(m.id)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${selectedMaterial === m.id
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left relative group ${selectedMaterial === m.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-white font-medium truncate">
|
||||
{m.scene || m.name}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm mt-1">
|
||||
{m.size_mb.toFixed(1)} MB
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedMaterial(m.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="text-white font-medium truncate pr-6">
|
||||
{m.scene || m.name}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm mt-1">
|
||||
{m.size_mb.toFixed(1)} MB
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMaterial(m.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="删除素材"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -471,16 +551,66 @@ export default function Home() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史视频列表 */}
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
📂 历史视频
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchGeneratedVideos}
|
||||
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
|
||||
>
|
||||
🔄 刷新
|
||||
</button>
|
||||
</div>
|
||||
{generatedVideos.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p>暂无生成的视频</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar">
|
||||
{generatedVideos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedVideoId(v.id);
|
||||
setGeneratedVideo(`${API_BASE}${v.path}`);
|
||||
}}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="text-white text-sm truncate">
|
||||
{new Date(v.created_at * 1000).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs">
|
||||
{v.size_mb.toFixed(1)} MB
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteVideo(v.id);
|
||||
}}
|
||||
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="删除视频"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-white/10 mt-12">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 text-center text-gray-500 text-sm">
|
||||
ViGent - 基于 MuseTalk + EdgeTTS
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import useSWR from 'swr';
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
import Link from "next/link";
|
||||
|
||||
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
||||
@@ -33,6 +36,7 @@ export default function PublishPage() {
|
||||
const [publishTime, setPublishTime] = useState<string>("");
|
||||
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
|
||||
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
|
||||
const [isLoadingQR, setIsLoadingQR] = useState(false);
|
||||
|
||||
// 加载账号和视频列表
|
||||
useEffect(() => {
|
||||
@@ -52,20 +56,18 @@ export default function PublishPage() {
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
// 获取已生成的视频列表 (从 outputs 目录)
|
||||
const res = await fetch(`${API_BASE}/api/videos/tasks`);
|
||||
// 使用持久化的视频列表 API(从文件系统读取)
|
||||
const res = await fetch(`${API_BASE}/api/videos/generated`);
|
||||
const data = await res.json();
|
||||
|
||||
const completedVideos = data.tasks
|
||||
?.filter((t: any) => t.status === "completed")
|
||||
.map((t: any) => ({
|
||||
name: `${t.task_id}_output.mp4`,
|
||||
path: `outputs/${t.task_id}_output.mp4`,
|
||||
})) || [];
|
||||
const videos = (data.videos || []).map((v: any) => ({
|
||||
name: new Date(v.created_at * 1000).toLocaleString('zh-CN') + ` (${v.size_mb.toFixed(1)}MB)`,
|
||||
path: v.path.startsWith('/') ? v.path.slice(1) : v.path, // 移除开头的 /
|
||||
}));
|
||||
|
||||
setVideos(completedVideos);
|
||||
if (completedVideos.length > 0) {
|
||||
setSelectedVideo(completedVideos[0].path);
|
||||
setVideos(videos);
|
||||
if (videos.length > 0) {
|
||||
setSelectedVideo(videos[0].path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取视频失败:", error);
|
||||
@@ -110,6 +112,12 @@ export default function PublishPage() {
|
||||
|
||||
const result = await res.json();
|
||||
setPublishResults((prev) => [...prev, result]);
|
||||
// 发布成功后10秒自动清除结果
|
||||
if (result.success) {
|
||||
setTimeout(() => {
|
||||
setPublishResults((prev) => prev.filter((r) => r !== result));
|
||||
}, 10000);
|
||||
}
|
||||
} catch (error) {
|
||||
setPublishResults((prev) => [
|
||||
...prev,
|
||||
@@ -121,7 +129,42 @@ export default function PublishPage() {
|
||||
setIsPublishing(false);
|
||||
};
|
||||
|
||||
// SWR Polling for Login Status
|
||||
const { data: loginStatus } = useSWR(
|
||||
qrPlatform ? `${API_BASE}/api/publish/login/status/${qrPlatform}` : null,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 2000,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
alert('✅ 登录成功!');
|
||||
fetchAccounts();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Timeout logic for QR code (business logic: stop after 2 mins)
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (qrPlatform) {
|
||||
timer = setTimeout(() => {
|
||||
if (qrPlatform) { // Double check active
|
||||
setQrPlatform(null);
|
||||
setQrCodeImage(null);
|
||||
alert('登录超时,请重试');
|
||||
}
|
||||
}, 120000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [qrPlatform]);
|
||||
|
||||
const handleLogin = async (platform: string) => {
|
||||
setIsLoadingQR(true);
|
||||
setQrPlatform(platform); // 立即显示加载弹窗
|
||||
setQrCodeImage(null); // 清空旧二维码
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
|
||||
method: 'POST'
|
||||
@@ -129,37 +172,35 @@ export default function PublishPage() {
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success && result.qr_code) {
|
||||
// 显示二维码
|
||||
setQrCodeImage(result.qr_code);
|
||||
setQrPlatform(platform);
|
||||
|
||||
// 轮询登录状态
|
||||
const checkInterval = setInterval(async () => {
|
||||
const statusRes = await fetch(`${API_BASE}/api/publish/login/status/${platform}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.success) {
|
||||
clearInterval(checkInterval);
|
||||
setQrCodeImage(null);
|
||||
setQrPlatform(null);
|
||||
alert('✅ 登录成功!');
|
||||
fetchAccounts(); // 刷新账号状态
|
||||
}
|
||||
}, 2000); // 每2秒检查一次
|
||||
|
||||
// 2分钟后停止轮询
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
if (qrCodeImage) {
|
||||
setQrCodeImage(null);
|
||||
alert('登录超时,请重试');
|
||||
}
|
||||
}, 120000);
|
||||
// SWR hook will automatically start polling since qrPlatform is set
|
||||
} else {
|
||||
setQrPlatform(null); // 失败时关闭弹窗
|
||||
alert(result.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
setQrPlatform(null); // 失败时关闭弹窗
|
||||
alert(`登录失败: ${error}`);
|
||||
} finally {
|
||||
setIsLoadingQR(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async (platform: string) => {
|
||||
if (!confirm('确定要注销登录吗?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/publish/logout/${platform}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
alert('已注销');
|
||||
fetchAccounts();
|
||||
} else {
|
||||
alert(result.message || '注销失败');
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`注销失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,20 +215,29 @@ export default function PublishPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900">
|
||||
{/* QR码弹窗 */}
|
||||
{qrCodeImage && (
|
||||
{qrPlatform && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-md">
|
||||
<div className="bg-white rounded-2xl p-8 max-w-md min-w-[320px]">
|
||||
<h2 className="text-2xl font-bold mb-4 text-center">🔐 扫码登录 {qrPlatform}</h2>
|
||||
<img
|
||||
src={`data:image/png;base64,${qrCodeImage}`}
|
||||
alt="QR Code"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
<p className="text-center text-gray-600 mt-4">
|
||||
请使用手机扫码登录
|
||||
</p>
|
||||
{isLoadingQR ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="animate-spin w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
<p className="text-gray-600 mt-4">正在获取二维码...</p>
|
||||
</div>
|
||||
) : qrCodeImage ? (
|
||||
<>
|
||||
<img
|
||||
src={`data:image/png;base64,${qrCodeImage}`}
|
||||
alt="QR Code"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
<p className="text-center text-gray-600 mt-4">
|
||||
请使用手机扫码登录
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setQrCodeImage(null)}
|
||||
onClick={() => { setQrCodeImage(null); setQrPlatform(null); }}
|
||||
className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
取消
|
||||
@@ -250,12 +300,31 @@ export default function PublishPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
🔐 扫码登录
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{account.logged_in ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
↻ 重新登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLogout(account.platform)}
|
||||
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
注销
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleLogin(account.platform)}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
🔐 扫码登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -281,7 +350,7 @@ export default function PublishPage() {
|
||||
<select
|
||||
value={selectedVideo}
|
||||
onChange={(e) => setSelectedVideo(e.target.value)}
|
||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white"
|
||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white custom-select cursor-pointer hover:border-purple-500/50 transition-colors"
|
||||
>
|
||||
{videos.map((v) => (
|
||||
<option key={v.path} value={v.path}>
|
||||
@@ -417,6 +486,11 @@ export default function PublishPage() {
|
||||
<span className="text-white">
|
||||
{platformIcons[result.platform]} {result.message}
|
||||
</span>
|
||||
{result.success && (
|
||||
<p className="text-green-400/80 text-sm mt-1">
|
||||
⏳ 审核一般需要几分钟,请耐心等待
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user