Compare commits

...

8 Commits

Author SHA1 Message Date
Kevin Wong
3a3df41904 优化界面 2026-01-23 10:38:03 +08:00
Kevin Wong
561d74e16d 更新 2026-01-23 10:07:35 +08:00
Kevin Wong
cfe21d8337 更新 2026-01-23 09:42:10 +08:00
Kevin Wong
3a76f9d0cf 更新 2026-01-22 17:15:42 +08:00
Kevin Wong
ad7ff7a385 界面优化 2026-01-22 11:14:42 +08:00
Kevin Wong
c7e2b4d363 文档更新 2026-01-22 09:54:32 +08:00
Kevin Wong
d5baa79448 文档更新 2026-01-22 09:52:29 +08:00
Kevin Wong
3db15cee4e 更新 2026-01-22 09:22:23 +08:00
29 changed files with 3948 additions and 272 deletions

View File

@@ -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,28 +54,7 @@ cd /home/rongye/ProgramFiles/ViGent2
---
## 步骤 3: 安装后端依赖
```bash
cd /home/rongye/ProgramFiles/ViGent2/backend
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate
# 安装 PyTorch (CUDA 12.1)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
# 安装其他依赖
pip install -r requirements.txt
# 安装 Playwright 浏览器 (社交发布用)
playwright install chromium
```
---
## 步骤 4: 部署 AI 模型 (LatentSync 1.6)
## 步骤 3: 部署 AI 模型 (LatentSync 1.6)
> ⚠️ **重要**LatentSync 需要独立的 Conda 环境和 **~18GB VRAM**。请**不要**直接安装在后端环境中。
@@ -83,33 +68,46 @@ playwright install chromium
4. 复制核心推理代码
5. 验证推理脚本
确保 LatentSync 部署成功后,再继续后续步骤。
---
## 步骤 5: 启动 LatentSync 常驻加速服务 (可选)
为了消除每次生成视频时的 30-40秒 模型加载时间,建议启动常驻服务:
**验证 LatentSync 部署**:
```bash
cd /home/rongye/ProgramFiles/ViGent2/models/LatentSync
# 后台启动服务 (自动读取 backend/.env 中的 GPU 配置)
nohup python -m scripts.server > server.log 2>&1 &
conda activate latentsync
python -m scripts.server # 测试能否启动Ctrl+C 退出
```
---
## 步骤 7: 配置环境变量
## 步骤 4: 安装后端依赖
```bash
cd /home/rongye/ProgramFiles/ViGent2/backend
# 复制配置模板 (默认配置已经就绪)
# 创建虚拟环境
python3 -m venv venv
source venv/bin/activate
# 安装 PyTorch (CUDA 12.1)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
# 安装 Python 依赖
pip install -r requirements.txt
# 安装 Playwright 浏览器(社交发布需要)
playwright install chromium
```
---
## 步骤 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` | 模型加速 |

535
Docs/DevLogs/Day7.md Normal file
View File

@@ -0,0 +1,535 @@
# Day 7: 社交媒体发布功能完善
**日期**: 2026-01-21
**目标**: 完成社交媒体发布模块 (80% → 100%)
---
## 📋 任务概览
| 任务 | 状态 |
|------|------|
| SuperIPAgent 架构分析 | ✅ 完成 |
| 优化技术方案制定 | ✅ 完成 |
| B站上传功能实现 | ⏳ 计划中 |
| 定时发布功能 | ⏳ 计划中 |
| 端到端测试 | ⏳ 待进行 |
---
## 🔍 架构优化分析
### SuperIPAgent social-auto-upload 优势
通过分析 `Temp\SuperIPAgent\social-auto-upload`,发现以下**更优设计**:
| 对比项 | 原方案 | 优化方案 ✅ |
|--------|--------|------------|
| **调度方式** | APScheduler (需额外依赖) | **平台 API 原生定时** |
| **B站上传** | Playwright 自动化 (不稳定) | **biliup 库 (官方)** |
| **架构** | 单文件服务 | **模块化 uploader/** |
| **Cookie** | 手动维护 | **自动扫码 + 持久化** |
### 核心优势
1. **更简单**: 无需 APScheduler,直接传时间给平台
2. **更稳定**: biliup 库比 Playwright 选择器可靠
3. **更易维护**: 每个平台独立 uploader 类
---
## 📝 技术方案变更
### 新增依赖
```bash
pip install biliup>=0.4.0
pip install playwright-stealth # 可选,反检测
```
### 移除依赖
```diff
- apscheduler==3.10.4 # 不再需要
```
### 文件结构
```
backend/app/services/
├── publish_service.py # 简化,统一接口
+ ├── uploader/ # 新增: 平台上传器
+ │ ├── base_uploader.py # 基类
+ │ ├── bilibili_uploader.py # B站 (biliup)
+ │ └── douyin_uploader.py # 抖音 (Playwright)
```
---
## 🎯 关键代码模式
### 统一接口
```python
# publish_service.py
async def publish(video_path, platform, title, tags, publish_time=None):
if platform == "bilibili":
uploader = BilibiliUploader(...)
result = await uploader.main()
return result
```
### B站上传 (biliup 库)
```python
from biliup.plugins.bili_webup import BiliBili
with BiliBili(data) as bili:
bili.login_by_cookies(cookie_data)
video_part = bili.upload_file(video_path)
ret = bili.submit() # 平台处理定时
```
---
## 📅 开发计划
### 下午 (11:56 - 14:30)
- ✅ 添加 `biliup>=0.4.0``requirements.txt`
- ✅ 创建 `uploader/` 模块结构
- ✅ 实现 `base_uploader.py` 基类
- ✅ 实现 `bilibili_uploader.py` (biliup 库)
- ✅ 实现 `douyin_uploader.py` (Playwright)
- ✅ 实现 `xiaohongshu_uploader.py` (Playwright)
- ✅ 实现 `cookie_utils.py` (自动 Cookie 生成)
- ✅ 简化 `publish_service.py` (集成所有 uploader)
- ✅ 前端添加定时发布时间选择器
---
## 🎉 实施成果
### 后端改动
1. **新增文件**:
- `backend/app/services/uploader/__init__.py`
- `backend/app/services/uploader/base_uploader.py` (87行)
- `backend/app/services/uploader/bilibili_uploader.py` (135行) - biliup 库
- `backend/app/services/uploader/douyin_uploader.py` (173行) - Playwright
- `backend/app/services/uploader/xiaohongshu_uploader.py` (166行) - Playwright
- `backend/app/services/uploader/cookie_utils.py` (113行) - Cookie 自动生成
- `backend/app/services/uploader/stealth.min.js` - 反检测脚本
2. **修改文件**:
- `backend/requirements.txt`: 添加 `biliup>=0.4.0`
- `backend/app/services/publish_service.py`: 集成所有 uploader (170行)
3. **核心特性**:
-**自动 Cookie 生成** (Playwright QR 扫码登录)
-**B站**: 使用 `biliup` 库 (官方稳定)
-**抖音**: Playwright 自动化
-**小红书**: Playwright 自动化
- ✅ 支持定时发布 (所有平台)
- ✅ stealth.js 反检测 (防止被识别为机器人)
- ✅ 模块化架构 (易于扩展)
### 前端改动
1. **修改文件**:
- `frontend/src/app/publish/page.tsx`: 添加定时发布 UI
2. **新增功能**:
- ✅ 立即发布/定时发布切换按钮
-`datetime-local` 时间选择器
- ✅ 自动传递 ISO 格式时间到后端
- ✅ 一键登录按钮 (自动弹出浏览器扫码)
---
## 🚀 部署步骤
### 1. 安装依赖
```bash
cd backend
pip install biliup>=0.4.0
# 或重新安装所有依赖
pip install -r requirements.txt
# 安装 Playwright 浏览器
playwright install chromium
```
### 2. 客户登录平台 (**极简3步**)
**操作流程**:
1. **拖拽书签**(仅首次)
- 点击前端"🔐 扫码登录"
- 将页面上的"保存登录"按钮拖到浏览器书签栏
2. **扫码登录**
- 点击"打开登录页"
- 扫码登录B站/抖音/小红书
3. **点击书签**
- 登录成功后,点击书签栏的"保存登录"书签
- 自动完成!
**客户实际操作**: 拖拽1次首次+ 扫码1次 + 点击书签1次 = **仅3步**
**下次登录**: 只需扫码 + 点击书签 = **2步**
### 3. 重启后端服务
```bash
cd backend
uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload
```
---
## ✅ Day 7 完成总结
### 核心成果
1. **QR码自动登录** ⭐⭐⭐⭐⭐
- Playwright headless模式提取二维码
- 前端弹窗显示二维码
- 后端自动监控登录状态
- Cookie自动保存
2. **多平台上传器架构**
- B站: biliup官方库
- 抖音: Playwright自动化
- 小红书: Playwright自动化
- stealth.js反检测
3. **定时发布功能**
- 前端datetime-local时间选择
- 平台API原生调度
- 无需APScheduler
4. **用户体验优化**
- 首页添加发布入口
- 视频生成后直接发布按钮
- 一键扫码登录(仅扫码)
**后端** (13个):
- `backend/requirements.txt`
- `backend/app/main.py`
- `backend/app/services/publish_service.py`
- `backend/app/services/qr_login_service.py` (新建)
- `backend/app/services/uploader/__init__.py` (新建)
- `backend/app/services/uploader/base_uploader.py` (新建)
- `backend/app/services/uploader/bilibili_uploader.py` (新建)
- `backend/app/services/uploader/douyin_uploader.py` (新建)
- `backend/app/services/uploader/xiaohongshu_uploader.py` (新建)
- `backend/app/services/uploader/cookie_utils.py` (新建)
- `backend/app/services/uploader/stealth.min.js` (新建)
- `backend/app/api/publish.py`
- `backend/app/api/login_helper.py` (新建)
**前端** (2个):
- `frontend/src/app/page.tsx`
- `frontend/src/app/publish/page.tsx`
---
## 📝 TODO (Day 8优化项)
### 用户体验优化
- [ ] **文件名保留**: 上传视频后保留原始文件名
- [ ] **视频持久化**: 刷新页面后保留生成的视频
### 功能增强
- [ ] 抖音/小红书实际测试
- [ ] 批量发布功能
- [ ] 发布历史记录
---
## 📊 测试清单
- [ ] Playwright 浏览器安装成功
- [ ] B站 Cookie 自动生成测试
- [ ] 抖音 Cookie 自动生成测试
- [ ] 小红书 Cookie 自动生成测试
- [ ] 测试 B站立即发布功能
- [ ] 测试抖音立即发布功能
- [ ] 测试小红书立即发布功能
- [ ] 测试定时发布功能
---
## ⚠️ 注意事项
1. **B站 Cookie 获取**
- 参考 `social-auto-upload/examples/get_bilibili_cookie.py`
- 或手动登录后导出 JSON
2. **定时发布原理**
- 前端收集时间
- 后端传给平台 API
- **平台自行处理调度** (无需 APScheduler)
3. **biliup 优势**
- 官方 API 支持
- 社区活跃维护
- 比 Playwright 更稳定
---
## 🔗 相关文档
- [SuperIPAgent social-auto-upload](file:///d:/CodingProjects/Antigravity/Temp/SuperIPAgent/social-auto-upload)
- [优化实施计划](implementation_plan.md)
- [Task Checklist](task.md)
---
## 🎨 UI 一致性优化 (16:00 - 16:35)
**问题**:导航栏不一致、页面偏移
- 首页 Logo 无法点击,发布页可点击
- 发布页多余标题"📤 社交媒体发布"
- 首页因滚动条向左偏移 15px
**修复**
- `frontend/src/app/page.tsx` - Logo 改为 `<Link>` 组件
- `frontend/src/app/publish/page.tsx` - 删除页面标题和顶端 padding
- `frontend/src/app/globals.css` - 隐藏滚动条(保留滚动功能)
**状态**:✅ 两页面完全对齐
---
## 🔍 QR 登录问题诊断 (16:05)
**问题**:所有平台 QR 登录超时 `Page.wait_for_selector: Timeout 10000ms exceeded`
**原因**
1. Playwright headless 模式被检测
2. 缺少 stealth.js 反检测
3. CSS 选择器可能过时
**状态**:✅ 已修复
---
## 🔧 QR 登录功能修复 (16:35 - 16:45)
### 实施方案
#### 1. 启用 Stealth 模式
```python
# 避免headless检测
browser = await playwright.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
]
)
```
#### 2. 配置真实浏览器特征
```python
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...',
locale='zh-CN',
timezone_id='Asia/Shanghai'
)
```
#### 3. 注入 stealth.js 脚本
```python
stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
if stealth_path.exists():
await page.add_init_script(path=str(stealth_path))
```
#### 4. 多选择器 Fallback 策略
```python
"bilibili": {
"qr_selectors": [
".qrcode-img img",
"canvas.qrcode-img",
"img[alt*='二维码']",
".login-scan-box img",
"#qrcode-img"
]
}
# Douyin: 4个选择器, Xiaohongshu: 4个选择器
```
#### 5. 增加等待时间
- 页面加载3s → 5s + `wait_until='networkidle'`
- 选择器超时10s → 30s
#### 6. 调试功能
```python
# 保存调试截图到 backend/debug_screenshots/
if not qr_element:
screenshot_path = debug_dir / f"{platform}_debug.png"
await page.screenshot(path=str(screenshot_path))
```
### 修改文件
**后端** (1个):
- `backend/app/services/qr_login_service.py` - 全面重构QR登录逻辑
### 结果
- ✅ 添加反检测措施stealth模式、真实UA
- ✅ 多选择器fallback每平台4-5个
- ✅ 等待时间优化5s + 30s
- ✅ 自动保存调试截图
- 🔄 待服务器测试验证
---
## 📋 文档规则优化 (16:42 - 17:10)
**问题**Doc_Rules需要优化避免误删历史内容、规范工具使用、防止任务清单遗漏
**优化内容(最终版)**
1. **智能修改判断标准**
- 场景1错误修正 → 直接替换/删除
- 场景2方案改进 → 保留+追加V1/V2
- 场景3同一天多次修改 → 合并为最终版本
2. **工具使用规范**
- ✅ 必须使用 `replace_file_content`
- ❌ 禁止命令行工具(避免编码错误)
3. **task_complete 完整性保障** (新增)
- ✅ 引入 "完整性检查清单" (4大板块逐项检查)
- ✅ 引入记忆口诀:"头尾时间要对齐,任务规划两手抓,里程碑上别落下"
4. **结构优化**
- 合并冗余章节
- 移除无关项目组件
**修改文件**
- `Docs/Doc_Rules.md` - 包含检查清单的最终完善版
---
## ⚡ QR 登录性能与显示优化 (17:30)
**问题**
1. **速度慢**: 顺序等待每个选择器 (30s timeout × N),导致加载极慢
2. **B站显示错乱**: Fallback 触发全页截图,而不是二维码区域
**优化方案**
1. **并行等待 (Performance)**:
- 使用 `wait_for_selector("s1, s2, s3")` 联合选择器
- Playwright 自动等待任意一个出现 (即时响应,不再单纯 sleep)
- 超时时间从 30s 单次改为 15s 总计
2. **选择器增强 (Accuracy)**:
- 由于 B站登录页改版旧选择器失效
- 新增 `div[class*='qrcode'] canvas``div[class*='qrcode'] img`
**修改文件**:
- `backend/app/services/qr_login_service.py`
---
## ⚡ QR 登录最终坚固化 (17:45)
**问题**
- 并行等待虽然消除了顺序延迟,但 **CSS 选择器仍然无法匹配** (Timeout 15000ms)
- 截图显示二维码可见,但 Playwright 认为不可见或未找到(可能涉及动态类名或 DOM 结构变化)
**解决方案 (三重保障)**
1. **策略 1**: CSS 联合选择器 (超时缩短为 5s快速试错)
2. **策略 2 (新)**: **文本锚点定位**
- 不不再依赖脆弱的 CSS 类名
- 直接搜索屏幕上的 "扫码登录" 文字
- 智能查找文字附近的 `<canvas>``<img>`
3. **策略 3 (调试)**: **HTML 源码导出**
- 如果都失败,除了截图外,自动保存 `bilibili_debug.html`
- 彻底分析页面结构的"核武器"
**修改文件**:
- `backend/app/services/qr_login_service.py` (v3 最终版)
---
## ⚡ QR 登录终极修复 (17:55)
**致命问题**
1. **监控闪退**: 后端使用 `async with async_playwright()`,导致函数返回时浏览器自动关闭,后台监控任务 (`_monitor_login_status`) 操作已关闭的页面报错 `TargetClosedError`
2. **仍有延迟**: 之前的策略虽然改进,但串行等待 CSS 超时 (5s) 仍不可避免。
**解决方案**
1. **生命周期重构 (Backend)**:
- 移除上下文管理器,改为 `self.playwright.start()` 手动启动
- 浏览器实例持久化到类属性 (`self.browser`)
- 仅在监控任务完成或超时后,在 `finally` 块中手动清理资源 (`_cleanup`)
2. **真·并行策略**:
- 使用 `asyncio.wait(tasks, return_when=FIRST_COMPLETED)`
- CSS选择器策略 和 文本定位策略 **同时运行**
- 谁先找到二维码,直接返回,取消另一个任务
- **延迟降至 0秒** (理论极限)
**修改文件**:
- `backend/app/services/qr_login_service.py` (v4 重构版)
---
## 🐛 并行逻辑 Bug 修复 (18:00)
**问题现象**:
- B站登录正常**抖音秒挂** ("所有策略失败")。
- 原因:代码逻辑是 `asyncio.wait(FIRST_COMPLETED)`,如果其中一个策略(如文本策略)不适用该平台,它会立即返回 `None`
- **BUG**: 代码收到 `None`错误地以为任务结束取消了还在运行的另一个策略CSS策略
**修复方案**:
1. **修正并行逻辑**:
- 如果一个任务完成了但没找到结果 (Result is None)**不取消** 其他任务。
- 继续等待剩下的 `pending` 任务,直到找到结果或所有任务都跑完。
2. **扩展文本策略**:
-**抖音 (Douyin)** 也加入到文本锚点定位的支持列表中。
- 增加关键词 `["扫码登录", "打开抖音", "抖音APP"]`
**修改文件**:
- `backend/app/services/qr_login_service.py` (v5 修正版)
---
## ⚡ 抖音文本策略优化 (18:10)
**问题**:
- 抖音页面也是动态渲染的,"扫码登录" 文字出现有延迟。
- 之前的 `get_by_text(...).count()` 是瞬间检查,如果页面还没加载完文字,直接返回 0 (失败)。
- 结果CSS 还在等,文本策略瞬间报空,导致最终还是没找到。
**优化方案**:
1. **智能等待**: 对每个关键词 (如 "使用手机抖音扫码") 增加 `wait_for(timeout=2000)`,给页面一点加载时间。
2. **扩大搜索圈**: 找到文字后,向父级查找 **5层** (之前是3层),以适应抖音复杂的 DOM 结构。
3. **尺寸过滤**: 增加 `width > 100` 判断,防止误匹配到头像或小图标。
**修改文件**:
- `backend/app/services/qr_login_service.py` (v6 抖音增强版)
**状态**: ✅ 抖音策略已强化
---
## ✅ 验证结果 (18:15)
**用户反馈**:
- B站成功获取 Cookie 并显示"已登录"状态。
- 抖音:成功获取 Cookie 并显示"已登录"状态。
- **结论**:
1. 并行策略 (`asyncio.wait`) 有效解决了等待延迟。
2. 文本锚点定位 (`get_by_text`) 有效解决了动态页面元素查找问题。
3. 生命周期重构 (`manual start/close`) 解决了后台任务闪退问题。
**下一步**:
- 进行实际视频发布测试。

113
Docs/DevLogs/Day8.md Normal file
View 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
View 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)

View File

@@ -10,12 +10,182 @@
|------|------|
| **默认更新** | 只更新 `DayN.md` |
| **按需更新** | `task_complete.md` 仅在用户**明确要求**时更新 |
| **增量追加** | 禁止覆盖/新建。请使用 replace/edit 工具插入新内容。 |
| **智能修改** | 错误→替换,改进→追加(见下方详细规则) |
| **先读后写** | 更新前先查看文件当前内容 |
| **日内合并** | 同一天的多次小修改合并为最终版本 |
---
## 📁 文件结构
## 🔍 修改原内容的判断标准
### 场景 1错误修正 → **替换/删除**
**条件**:之前的方法/方案**无法工作**或**逻辑错误**
**操作**
- ✅ 直接替换为正确内容
- ✅ 添加一行修正说明:`> **修正 (HH:MM)**[错误原因],已更新`
- ❌ 不保留错误方法(避免误导)
**示例**
```markdown
## 🔧 XXX功能修复
~~旧方法:增加超时时间(无效)~~
> **修正 (16:20)**单纯超时无法解决已更新为Stealth模式
### 解决方案
- 启用Stealth模式...
```
### 场景 2方案改进 → **保留+追加**
**条件**:之前的方法**可以工作**,后来发现**更好的方法**
**操作**
- ✅ 保留原方法(标注版本 V1/V2
- ✅ 追加新方法
- ✅ 说明改进原因
**示例**
```markdown
## ⚡ 性能优化
### V1: 基础实现 (Day 5)
- 单线程处理 ✅
### V2: 性能优化 (Day 7)
- 多线程并发
- 速度提升 3x ⚡
```
### 场景 3同一天多次修改 → **合并**
**条件**:同一天内对同一功能的多次小改动
**操作**
- ✅ 直接更新为最终版本
- ❌ 不记录中间的每次迭代
- ✅ 可注明"多次优化后"
---
## 🔍 更新前检查清单
> **核心原则**:追加前先查找,避免重复和遗漏
### 必须执行的检查步骤
**1. 快速浏览全文**(使用 `view_file``grep_search`
```markdown
# 检查是否存在:
- 同主题的旧章节?
- 待更新的状态标记(🔄 待验证)?
- 未完成的TODO项
```
**2. 判断操作类型**
| 情况 | 操作 |
|------|------|
| **有相关旧内容且错误** | 替换场景1 |
| **有相关旧内容可改进** | 追加V2场景2 |
| **有待验证状态** | 更新状态标记 |
| **全新独立内容** | 追加到末尾 |
**3. 必须更新的内容**
-**状态标记**`🔄 待验证``✅ 已修复` / `❌ 失败`
-**进度百分比**:更新为最新值
-**文件修改列表**:补充新修改的文件
-**禁止**:创建重复的章节标题
### 示例场景
**错误示例**(未检查旧内容):
```markdown
## 🔧 QR登录修复 (15:00)
**状态**:🔄 待验证
## 🔧 QR登录修复 (16:00) ❌ 重复!
**状态**:✅ 已修复
```
**正确做法**
```markdown
## 🔧 QR登录修复 (15:00)
**状态**:✅ 已修复 ← 直接更新原状态
```
---
## <20> 工具使用规范
> **核心原则**:使用正确的工具,避免字符编码问题
### ✅ 推荐工具replace_file_content
**使用场景**
- 追加新章节到文件末尾
- 修改/替换现有章节内容
- 更新状态标记(🔄 → ✅)
- 修正错误内容
**优势**
- ✅ 自动处理字符编码Windows CRLF
- ✅ 精确替换,不会误删其他内容
- ✅ 有错误提示,方便调试
**注意事项**
```markdown
1. **必须精确匹配**TargetContent 必须与文件完全一致
2. **处理换行符**:文件使用 \r\n不要漏掉 \r
3. **合理范围**StartLine/EndLine 应覆盖目标内容
4. **先读后写**:编辑前先 view_file 确认内容
```
### ❌ 禁止使用:命令行工具
**禁止场景**
- ❌ 使用 `echo >>` 追加内容(编码问题)
- ❌ 使用 PowerShell 直接修改文档(破坏格式)
- ❌ 使用 sed/awk 等命令行工具
**原因**
- 容易破坏 UTF-8 编码
- Windows CRLF vs Unix LF 混乱
- 难以追踪修改,容易出错
**唯一例外**:简单的全局文本替换(如批量更新日期),且必须使用 `-NoNewline` 参数
### 📝 最佳实践示例
**追加新章节**
```python
replace_file_content(
TargetFile="path/to/DayN.md",
TargetContent="## 🔗 相关文档\n\n...\n\n", # 文件末尾的内容
ReplacementContent="## 🔗 相关文档\n\n...\n\n---\n\n## 🆕 新章节\n内容...",
StartLine=280,
EndLine=284
)
```
**修改现有内容**
```python
replace_file_content(
TargetContent="**状态**:🔄 待修复",
ReplacementContent="**状态**:✅ 已修复",
StartLine=310,
EndLine=310
)
```
---
## <20>📁 文件结构
```
ViGent/Docs/
@@ -28,12 +198,28 @@ 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 更新规则(日常更新)
### 新建判断
- 检查最新 `DayN.md` 的日期
- **今天** → 追加到现有文件
- **之前** → 创建 `Day{N+1}.md`
### 新建判断 (对话开始前)
1. **回顾进度**:查看 `task_complete.md` 了解当前状态
2. **检查日期**:查看最新 `DayN.md`
- **今天** → 追加到现有文件
- **之前** → 创建 `Day{N+1}.md`
### 追加格式
```markdown
@@ -62,6 +248,24 @@ ViGent/Docs/
**状态**:✅ 已修复 / 🔄 待验证
```
---
## 📏 内容简洁性规则
### 代码示例长度控制
- **原则**只展示关键代码片段10-20行以内
- **超长代码**:使用 `// ... 省略 ...` 或仅列出文件名+行号
- **完整代码**:引用文件链接,而非粘贴全文
### 调试信息处理
- **临时调试**:验证后删除(如调试日志、测试截图)
- **有价值信息**:保留(如错误日志、性能数据)
### 状态标记更新
- **🔄 待验证** → 验证后更新为 **✅ 已修复** 或 **❌ 失败**
- 直接修改原状态,无需追加新行
---
## 📝 task_complete.md 更新规则(仅按需)
@@ -72,25 +276,29 @@ ViGent/Docs/
- **格式一致性**:直接参考 `task_complete.md` 现有格式追加内容。
- **进度更新**:仅在阶段性里程碑时更新进度百分比。
---
### 🔍 完整性检查清单 (必做)
## 🚀 新对话检查清单
每次更新 `task_complete.md` 时,必须**逐一检查**以下所有板块:
1. 查看 `task_complete.md` → 了解整体进度
2. 查看最新 `DayN.md` → 确认今天是第几天
3. 根据日期决定追加或新建 Day 文件
1. **文件头部 & 导航**
- [ ] `更新时间`:必须是当天日期
- [ ] `整体进度`:简述当前状态
- [ ] `快速导航`Day 范围与文档一致
2. **核心任务区**
- [ ] `已完成任务`:添加新的 [x] 项目
- [ ] `后续规划`:管理三色板块 (优先/债务/未来)
3. **统计与回顾**
- [ ] `进度统计`:更新对应模块状态和百分比
- [ ] `里程碑`:若有重大进展,追加 `## Milestone N`
4. **底部链接**
- [ ] `时间线`:追加今日概括
- [ ] `相关文档`:更新 DayLog 链接范围
> **口诀**:头尾时间要对齐,任务规划两手抓,里程碑上别落下。
---
## 🎯 项目组件
| 组件 | 位置 |
|------|------|
| 后端 (FastAPI) | `ViGent/backend/` |
| 前端 (Next.js) | `ViGent/frontend/` |
| AI 模型 (MuseTalk) | `ViGent/models/` |
| 文档 | `ViGent/Docs/` |
---
**最后更新**2026-01-13
**最后更新**2026-01-21

View File

@@ -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] 完整类型提示
---
## 项目目录结构 (最终)

View File

@@ -2,8 +2,8 @@
**项目**ViGent2 数字人口播视频生成系统
**服务器**Dell R730 (2× RTX 3090 24GB)
**更新时间**2026-01-20
**整体进度**100%Day 6 LatentSync 1.6 升级完成)
**更新时间**2026-01-23
**整体进度**100%Day 9 发布模块优化完成)
## 📖 快速导航
@@ -16,7 +16,7 @@
| [时间线](#-时间线) | 开发历程 |
**相关文档**
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-6)
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day9)
- [部署指南](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
---
@@ -45,7 +45,8 @@
- [x] Playwright 自动化框架
- [x] Cookie 管理功能
- [x] 多平台发布 UI
- [ ] 定时发布功能
- [x] 定时发布功能 (Day 7)
- [x] QR码自动登录 (Day 7)
### 阶段五:部署与文档
- [x] 手动部署指南 (DEPLOY_MANUAL.md)
@@ -89,6 +90,35 @@
- [x] 预加载模型服务 (常驻 Server + FastAPI)
- [x] 批量队列处理 (GPU 并发控制)
### 阶段十一:社交媒体发布完善 (Day 7)
- [x] QR码自动登录 (Playwright headless)
- [x] 多平台上传器架构 (B站/抖音/小红书)
- [x] B站发布 (biliup官方库)
- [x] 抖音/小红书发布 (Playwright)
- [x] 定时发布功能
- [x] 前端发布UI优化
- [x] Cookie自动管理
- [x] UI一致性修复 (导航栏对齐、滚动条隐藏)
- [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] 完整类型提示
---
## 🛤️ 后续规划
@@ -96,10 +126,10 @@
### 🔴 优先待办
- [x] 视频合成最终验证 (MP4生成) ✅ Day 4 完成
- [x] 端到端流程完整测试 ✅ Day 4 完成
- [ ] 社交媒体发布测试
- [x] 社交媒体发布测试 ✅ Day 9 完成 (B站/抖音登录+发布)
### 🟠 功能完善
- [ ] 定时发布功能
- [x] 定时发布功能 ✅ Day 7 完成
- [ ] 批量视频生成
- [ ] 字幕样式编辑器
@@ -126,7 +156,7 @@
| TTS 配音 | 100% | ✅ 完成 |
| 视频合成 | 100% | ✅ 完成 |
| 唇形同步 | 100% | ✅ LatentSync 1.6 升级完成 |
| 社交发布 | 80% | 🔄 框架完成,待测试 |
| 社交发布 | 100% | ✅ Day 9 验证通过 |
| 服务器部署 | 100% | ✅ 完成 |
---
@@ -204,5 +234,25 @@ Day 6: LatentSync 1.6 升级 ✅ 完成
- 模型部署指南
- 服务器部署验证
- 性能优化 (视频预压缩、进度更新)
Day 7: 社交媒体发布完善 ✅ 完成
- QR码自动登录 (B站/抖音验证通过)
- 智能定位策略 (CSS/Text并行)
- 多平台发布 (B站/抖音/小红书)
- UI 一致性优化
- 文档规则体系优化
Day 8: 用户体验优化 ✅ 完成
- 文件名保留 (时间戳前缀)
- 视频持久化 (历史视频API)
- 历史视频列表组件
- 素材/视频删除功能
Day 9: 发布模块优化 ✅ 完成
- B站/抖音登录+发布验证通过
- 资源清理保障 (try-finally)
- 超时保护 (消除无限循环)
- 小红书 headless 模式修复
- 完整类型提示
```

View File

@@ -10,7 +10,7 @@
- 🎬 **唇形同步** - LatentSync 1.6 驱动512×512 高分辨率 Diffusion 模型
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
- 📱 **一键发布** - Playwright 自动发布到抖音小红书、B站等
- 📱 **全自动发布** - 扫码登录 + Cookie持久化支持多平台(B站/抖音/小红书)定时发布
- 🖥️ **Web UI** - Next.js 现代化界面
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)

View File

@@ -0,0 +1,221 @@
"""
前端一键扫码登录辅助页面
客户在自己的浏览器中扫码JavaScript自动提取Cookie并上传到服务器
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from app.core.config import settings
router = APIRouter()
@router.get("/login-helper/{platform}", response_class=HTMLResponse)
async def login_helper_page(platform: str, request: Request):
"""
提供一个HTML页面让用户在自己的浏览器中登录平台
登录后JavaScript自动提取Cookie并POST回服务器
"""
platform_urls = {
"bilibili": "https://www.bilibili.com/",
"douyin": "https://creator.douyin.com/",
"xiaohongshu": "https://creator.xiaohongshu.com/"
}
platform_names = {
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书"
}
if platform not in platform_urls:
return "<h1>不支持的平台</h1>"
# 获取服务器地址用于回传Cookie
server_url = str(request.base_url).rstrip('/')
html_content = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{platform_names[platform]} 一键登录</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 20px;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}}
.container {{
background: white;
border-radius: 20px;
padding: 50px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 700px;
width: 100%;
}}
h1 {{
color: #333;
margin: 0 0 30px 0;
text-align: center;
font-size: 32px;
}}
.step {{
display: flex;
align-items: flex-start;
margin: 25px 0;
padding: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12px;
border-left: 5px solid #667eea;
}}
.step-number {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 20px;
margin-right: 20px;
flex-shrink: 0;
}}
.step-content {{
flex: 1;
}}
.step-title {{
font-weight: 600;
font-size: 18px;
margin-bottom: 8px;
color: #333;
}}
.step-desc {{
color: #666;
line-height: 1.6;
}}
.bookmarklet {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 30px;
border-radius: 10px;
text-decoration: none;
display: inline-block;
font-weight: 600;
font-size: 18px;
margin: 20px 0;
cursor: move;
border: 3px dashed white;
transition: transform 0.2s;
}}
.bookmarklet:hover {{
transform: scale(1.05);
}}
.bookmarklet-container {{
text-align: center;
margin: 30px 0;
padding: 30px;
background: #f8f9fa;
border-radius: 12px;
}}
.instruction {{
font-size: 14px;
color: #666;
margin-top: 10px;
}}
.highlight {{
background: #fff3cd;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}}
.btn {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
border-radius: 10px;
font-size: 18px;
cursor: pointer;
font-weight: 600;
width: 100%;
margin-top: 20px;
transition: transform 0.2s;
}}
.btn:hover {{
transform: translateY(-2px);
}}
</style>
</head>
<body>
<div class="container">
<h1>🔐 {platform_names[platform]} 一键登录</h1>
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<div class="step-title">拖拽书签到书签栏</div>
<div class="step-desc">
将下方的"<span class="highlight">保存{platform_names[platform]}登录</span>"按钮拖拽到浏览器书签栏
<br><small>(如果书签栏未显示,按 Ctrl+Shift+B 显示)</small>
</div>
</div>
</div>
<div class="bookmarklet-container">
<a href="javascript:(function(){{var c=document.cookie;if(!c){{alert('请先登录{platform_names[platform]}');return;}}fetch('{server_url}/api/publish/cookies/save/{platform}',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{cookie_string:c}})}}).then(r=>r.json()).then(d=>{{if(d.success){{alert('✅ 登录成功!');window.opener&&window.opener.location.reload();}}else{{alert(''+d.message);}}}}
).catch(e=>alert('提交失败:'+e));}})();"
class="bookmarklet"
onclick="alert('请拖拽此按钮到书签栏,不要点击!'); return false;">
🔖 保存{platform_names[platform]}登录
</a>
<div class="instruction">
⬆️ <strong>拖拽此按钮到浏览器顶部书签栏</strong>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<div class="step-title">登录 {platform_names[platform]}</div>
<div class="step-desc">
点击下方按钮打开{platform_names[platform]}登录页,扫码登录
</div>
</div>
</div>
<button class="btn" onclick="window.open('{platform_urls[platform]}', 'login_tab')">
🚀 打开{platform_names[platform]}登录页
</button>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<div class="step-title">一键保存登录</div>
<div class="step-desc">
登录成功后,点击书签栏的"<span class="highlight">保存{platform_names[platform]}登录</span>"书签
<br>系统会自动提取并保存Cookie完成
</div>
</div>
</div>
<hr style="margin: 40px 0; border: none; border-top: 2px solid #eee;">
<div style="text-align: center; color: #999; font-size: 14px;">
<p>💡 <strong>提示</strong>:书签只需拖拽一次,下次登录直接点击书签即可</p>
<p>🔒 所有数据仅在您的浏览器和服务器之间传输,安全可靠</p>
</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content)

View File

@@ -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)}")

View File

@@ -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,4 +69,52 @@ async def list_accounts():
@router.post("/login/{platform}")
async def login_platform(platform: str):
return await publish_service.login(platform)
"""触发平台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):
"""检查登录状态 (优先检查活跃的扫码会话)"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
return publish_service.get_login_session_status(platform)
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict):
"""
保存从客户端浏览器提取的Cookie
Args:
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"):
return result
else:
raise HTTPException(status_code=400, detail=result.get("message"))

View File

@@ -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)}")

View File

@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.core import config
from app.api import materials, videos, publish
from app.api import materials, videos, publish, login_helper
settings = config.settings
@@ -26,6 +26,7 @@ app.mount("/outputs", StaticFiles(directory=str(settings.OUTPUT_DIR)), name="out
app.include_router(materials.router, prefix="/api/materials", tags=["Materials"])
app.include_router(videos.router, prefix="/api/videos", tags=["Videos"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
app.include_router(login_helper.router, prefix="/api", tags=["LoginHelper"])
@app.get("/health")
def health():

View File

@@ -1,27 +1,39 @@
"""
发布服务 (Playwright)
发布服务 (基于 social-auto-upload 架构)
"""
from playwright.async_api import async_playwright
from pathlib import Path
import json
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Dict, Any
from loguru import logger
from app.core.config import settings
# Import platform uploaders
from .uploader.bilibili_uploader import BilibiliUploader
from .uploader.douyin_uploader import DouyinUploader
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
class PublishService:
PLATFORMS = {
"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/"},
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame"},
"""Social media publishing service"""
# 支持的平台配置
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)
def get_accounts(self):
# 存储活跃的登录会话,用于跟踪登录状态
self.active_login_sessions: Dict[str, Any] = {}
def get_accounts(self) -> List[Dict[str, Any]]:
"""Get list of platform accounts with login status"""
accounts = []
for pid, pinfo in self.PLATFORMS.items():
cookie_file = self.cookies_dir / f"{pid}_cookies.json"
@@ -29,43 +41,230 @@ class PublishService:
"platform": pid,
"name": pinfo["name"],
"logged_in": cookie_file.exists(),
"enabled": True
"enabled": pinfo.get("enabled", True)
})
return accounts
async def login(self, platform: str):
if platform not in self.PLATFORMS:
raise ValueError("Unsupported platform")
pinfo = self.PLATFORMS[platform]
logger.info(f"Logging in to {platform}...")
async def publish(
self,
video_path: str,
platform: str,
title: str,
tags: List[str],
description: str = "",
publish_time: Optional[datetime] = None,
**kwargs: Any
) -> Dict[str, Any]:
"""
Publish video to specified platform
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context()
page = await context.new_page()
Args:
video_path: Path to video file
platform: Platform ID (bilibili, douyin, etc.)
title: Video title
tags: List of tags
description: Video description
publish_time: Scheduled publish time (None = immediate)
**kwargs: Additional platform-specific parameters
await page.goto(pinfo["url"])
logger.info("Please login manually in the browser window...")
Returns:
dict: Publish result
"""
# Validate platform
if platform not in self.PLATFORMS:
logger.error(f"[发布] 不支持的平台: {platform}")
return {
"success": False,
"message": f"不支持的平台: {platform}",
"platform": platform
}
# Get account file path
account_file = self.cookies_dir / f"{platform}_cookies.json"
logger.info(f"[发布] 平台: {self.PLATFORMS[platform]['name']}")
logger.info(f"[发布] 视频: {video_path}")
logger.info(f"[发布] 标题: {title}")
try:
# Select appropriate uploader
if platform == "bilibili":
uploader = BilibiliUploader(
title=title,
file_path=str(settings.BASE_DIR.parent / video_path), # Convert to absolute path
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description,
tid=kwargs.get('tid', 122), # Category ID
copyright=kwargs.get('copyright', 1) # 1=original
)
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
)
else:
logger.warning(f"[发布] {platform} 上传功能尚未实现")
return {
"success": False,
"message": f"{self.PLATFORMS[platform]['name']} 上传功能开发中",
"platform": platform
}
# Wait for user input (naive check via title or url change, or explicit timeout)
# For simplicity in restore, wait for 60s or until manually closed?
# In a real API, this blocks.
# We implemented a simplistic wait in the previous iteration.
try:
await page.wait_for_timeout(45000) # Give user 45s to login
cookies = await context.cookies()
cookie_path = self.cookies_dir / f"{platform}_cookies.json"
with open(cookie_path, "w") as f:
json.dump(cookies, f)
return {"success": True, "message": f"Login {platform} successful"}
except Exception as e:
return {"success": False, "message": str(e)}
finally:
await browser.close()
# Execute upload
result = await uploader.main()
result['platform'] = platform
return result
except Exception as e:
logger.exception(f"[发布] 上传异常: {e}")
return {
"success": False,
"message": f"上传异常: {str(e)}",
"platform": platform
}
async def login(self, platform: str) -> Dict[str, Any]:
"""
启动QR码登录流程
Returns:
dict: 包含二维码base64图片
"""
if platform not in self.PLATFORMS:
return {"success": False, "message": "不支持的平台"}
try:
from .qr_login_service import QRLoginService
# 创建QR登录服务
qr_service = QRLoginService(platform, self.cookies_dir)
# 存储活跃会话
self.active_login_sessions[platform] = qr_service
# 启动登录并获取二维码
result = await qr_service.start_login()
return result
except Exception as e:
logger.exception(f"[登录] QR码登录失败: {e}")
return {
"success": False,
"message": f"登录失败: {str(e)}"
}
async def publish(self, video_path: str, platform: str, title: str, **kwargs):
# Placeholder for actual automation logic
# Real implementation requires complex selectors per platform
await asyncio.sleep(2)
return {"success": True, "message": f"Published to {platform} (Mock)", "url": ""}
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": "未登录"}
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字符串
Args:
platform: 平台ID
cookie_string: document.cookie 格式的Cookie字符串
"""
try:
account_file = self.cookies_dir / f"{platform}_cookies.json"
# 解析Cookie字符串
cookie_dict = {}
for item in cookie_string.split('; '):
if '=' in item:
name, value = item.split('=', 1)
cookie_dict[name] = value
# 对B站进行特殊处理提取biliup需要的字段
if platform == "bilibili":
bilibili_cookies = {}
required_fields = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
for field in required_fields:
if field in cookie_dict:
bilibili_cookies[field] = cookie_dict[field]
if len(bilibili_cookies) < 3: # 至少需要3个关键字段
return {
"success": False,
"message": "Cookie不完整请确保已登录"
}
cookie_dict = bilibili_cookies
# 保存Cookie
with open(account_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
logger.success(f"[登录] {platform} Cookie已保存")
return {
"success": True,
"message": f"{self.PLATFORMS[platform]['name']} 登录成功"
}
except Exception as e:
logger.exception(f"[登录] Cookie保存失败: {e}")
return {
"success": False,
"message": f"Cookie保存失败: {str(e)}"
}

View File

@@ -0,0 +1,344 @@
"""
QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
import asyncio
import base64
import json
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码登录服务"""
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
self.cookies_dir = cookies_dir
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 = {
"bilibili": {
"url": "https://passport.bilibili.com/login",
"qr_selectors": [
"div[class*='qrcode'] canvas", # 常见canvas二维码
"div[class*='qrcode'] img", # 常见图片二维码
".qrcode-img img", # 旧版
".login-scan-box img", # 扫码框
"div[class*='scan'] img"
],
"success_indicator": "https://www.bilibili.com/"
},
"douyin": {
"url": "https://creator.douyin.com/",
"qr_selectors": [
".qrcode img", # 优先尝试
"img[alt='qrcode']",
"canvas[class*='qr']",
"img[src*='qr']"
],
"success_indicator": "https://creator.douyin.com/creator-micro"
},
"xiaohongshu": {
"url": "https://creator.xiaohongshu.com/",
"qr_selectors": [
".qrcode img",
"img[alt*='二维码']",
"canvas.qr-code",
"img[class*='qr']"
],
"success_indicator": "https://creator.xiaohongshu.com/publish"
}
}
async def start_login(self) -> Dict[str, Any]:
"""
启动登录流程
Returns:
dict: 包含二维码base64和状态
"""
if self.platform not in self.platform_configs:
return {"success": False, "message": "不支持的平台"}
config = self.platform_configs[self.platform]
try:
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
# Stealth模式启动浏览器
self.browser = await self.playwright.chromium.launch(
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-dev-shm-usage'
]
)
# 配置真实浏览器特征
self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale='zh-CN',
timezone_id='Asia/Shanghai'
)
page = await self.context.new_page()
# 注入stealth.js
stealth_path = Path(__file__).parent / 'uploader' / 'stealth.min.js'
if stealth_path.exists():
await page.add_init_script(path=str(stealth_path))
logger.debug(f"[{self.platform}] Stealth模式已启用")
logger.info(f"[{self.platform}] 打开登录页...")
await page.goto(config["url"], wait_until='networkidle')
# 等待页面加载 (缩短等待)
await asyncio.sleep(2)
# 提取二维码 (并行策略)
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
if not qr_image:
await self._cleanup()
return {"success": False, "message": "未找到二维码"}
logger.info(f"[{self.platform}] 二维码已获取,等待扫码...")
# 启动后台监控任务 (浏览器保持开启)
asyncio.create_task(
self._monitor_login_status(page, config["success_indicator"])
)
return {
"success": True,
"qr_code": qr_image,
"message": "请扫码登录"
}
except Exception as e:
logger.exception(f"[{self.platform}] 启动登录失败: {e}")
await self._cleanup()
return {"success": False, "message": f"启动失败: {str(e)}"}
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
"""
提取二维码图片 (优化策略顺序)
根据日志分析抖音和B站使用 Text 策略成功率最高
"""
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): 匹配成功")
qr_element = el
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
# 策略2: Text
if not qr_element:
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.error(f"[{self.platform}] 截图失败: {e}")
# 所有策略失败
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"))
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):
"""监控登录状态"""
try:
logger.info(f"[{self.platform}] 开始监控登录状态...")
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
target_cookie = key_cookies.get(self.platform, "")
for i in range(self.LOGIN_TIMEOUT):
await asyncio.sleep(1)
try:
if not self.context: break # 避免意外关闭
cookies = await self.context.cookies()
current_url = page.url
has_cookie = any(c['name'] == target_cookie for c in cookies)
if i % 5 == 0:
logger.debug(f"[{self.platform}] 等待登录... HasCookie: {has_cookie}")
if success_url in current_url or has_cookie:
logger.success(f"[{self.platform}] 登录成功!")
self.login_success = True
await asyncio.sleep(2) # 缓冲
# 保存Cookie
final_cookies = await self.context.cookies()
await self._save_cookies(final_cookies)
break
except Exception as e:
logger.warning(f"[{self.platform}] 监控循环警告: {e}")
break
if not self.login_success:
logger.warning(f"[{self.platform}] 登录超时")
except Exception as e:
logger.error(f"[{self.platform}] 监控异常: {e}")
finally:
await self._cleanup()
async def _cleanup(self) -> None:
"""清理资源"""
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[Dict[str, Any]]) -> None:
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
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
logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
def get_login_status(self) -> Dict[str, Any]:
"""获取登录状态"""
return {
"success": self.login_success,
"cookies_saved": self.cookies_data is not None
}

View File

@@ -0,0 +1,9 @@
"""
Platform uploader base classes and utilities
"""
from .base_uploader import BaseUploader
from .bilibili_uploader import BilibiliUploader
from .douyin_uploader import DouyinUploader
from .xiaohongshu_uploader import XiaohongshuUploader
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader']

View File

@@ -0,0 +1,65 @@
"""
Base uploader class for all social media platforms
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
class BaseUploader(ABC):
"""Base class for all platform uploaders"""
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
"""
Initialize base uploader
Args:
title: Video title
file_path: Path to video file
tags: List of tags/hashtags
publish_date: Scheduled publish time (None = publish immediately)
account_file: Path to account cookie/credentials file
description: Video description
"""
self.title = title
self.file_path = Path(file_path)
self.tags = tags
self.publish_date = publish_date if publish_date else 0 # 0 = immediate
self.account_file = account_file
self.description = description
@abstractmethod
async def main(self) -> Dict[str, Any]:
"""
Main upload method - must be implemented by subclasses
Returns:
dict: Upload result with keys:
- success (bool): Whether upload succeeded
- message (str): Result message
- url (str, optional): URL of published video
"""
pass
def _get_timestamp(self, dt: Union[datetime, int]) -> int:
"""
Convert datetime to Unix timestamp
Args:
dt: datetime object or 0 for immediate publish
Returns:
int: Unix timestamp or 0
"""
if dt == 0:
return 0
return int(dt.timestamp())

View File

@@ -0,0 +1,172 @@
"""
Bilibili uploader using biliup library
"""
import json
import asyncio
from pathlib import Path
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
BILIUP_AVAILABLE = True
except ImportError:
BILIUP_AVAILABLE = False
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"""
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = "",
tid: int = 122, # 分区ID: 122=国内原创
copyright: int = 1 # 1=原创, 2=转载
):
"""
Initialize Bilibili uploader
Args:
tid: Bilibili category ID (default: 122 for 国内原创)
copyright: 1 for original, 2 for repost
"""
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.tid = tid
self.copyright = copyright
if not BILIUP_AVAILABLE:
raise ImportError(
"biliup library not installed. Please run: pip install biliup"
)
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():
logger.error(f"[B站] Cookie 文件不存在: {self.account_file}")
return {
"success": False,
"message": "Cookie 文件不存在,请先登录",
"url": None
}
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
data.title = self.title
data.desc = self.description or f"标签: {', '.join(self.tags)}"
data.tid = self.tid
data.set_tag(self.tags)
data.dtime = self._get_timestamp(self.publish_date)
logger.info(f"[B站] 开始上传: {self.file_path.name}")
logger.info(f"[B站] 标题: {self.title}")
logger.info(f"[B站] 定时发布: {'' if data.dtime > 0 else ''}")
# 3. Upload video
with BiliBili(data) as bili:
# Login with cookies
bili.login_by_cookies(cookie_data)
bili.access_token = cookie_data.get('access_token', '')
# Upload file (3 threads, auto line selection)
video_part = bili.upload_file(
str(self.file_path),
lines='AUTO',
tasks=3
)
video_part['title'] = self.title
data.append(video_part)
# Submit
ret = bili.submit()
# Debug: log full response
logger.debug(f"[B站] API响应: {ret}")
if ret.get('code') == 0:
# 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} (完整响应: {ret})")
return {
"success": False,
"message": f"上传失败: {error_msg}",
"url": None
}
except Exception as e:
logger.exception(f"[B站] 上传异常: {e}")
return {
"success": False,
"message": f"上传异常: {str(e)}",
"url": None
}

View File

@@ -0,0 +1,107 @@
"""
Utility functions for cookie management and Playwright setup
"""
from pathlib import Path
from playwright.async_api import async_playwright
import json
from loguru import logger
from app.core.config import settings
async def set_init_script(context):
"""
Add stealth script to prevent bot detection
Args:
context: Playwright browser context
Returns:
Modified context
"""
# Add stealth.js if available
stealth_js_path = settings.BASE_DIR / "app" / "services" / "uploader" / "stealth.min.js"
if stealth_js_path.exists():
await context.add_init_script(path=stealth_js_path)
# Grant geolocation permission
await context.grant_permissions(['geolocation'])
return context
async def generate_cookie_with_qr(platform: str, platform_url: str, account_file: str):
"""
Generate cookie by scanning QR code with Playwright
Args:
platform: Platform name (for logging)
platform_url: Platform login URL
account_file: Path to save cookies
Returns:
bool: Success status
"""
try:
logger.info(f"[{platform}] 开始自动生成 Cookie...")
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=False)
context = await browser.new_context()
# Add stealth script
context = await set_init_script(context)
page = await context.new_page()
await page.goto(platform_url)
logger.info(f"[{platform}] 请在浏览器中扫码登录...")
logger.info(f"[{platform}] 登录后点击 Playwright Inspector 的 '继续' 按钮")
# Pause for user to login
await page.pause()
# Save cookies
await context.storage_state(path=account_file)
await browser.close()
logger.success(f"[{platform}] Cookie 已保存到: {account_file}")
return True
except Exception as e:
logger.exception(f"[{platform}] Cookie 生成失败: {e}")
return False
async def extract_bilibili_cookies(account_file: str):
"""
Extract specific Bilibili cookies needed by biliup
Args:
account_file: Path to cookies file
Returns:
dict: Extracted cookies
"""
try:
# Read Playwright storage_state format
with open(account_file, 'r', encoding='utf-8') as f:
storage = json.load(f)
# Extract cookies
cookie_dict = {}
for cookie in storage.get('cookies', []):
if cookie['name'] in ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']:
cookie_dict[cookie['name']] = cookie['value']
# Save in biliup format
with open(account_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
logger.info(f"[B站] Cookie 已转换为 biliup 格式")
return cookie_dict
except Exception as e:
logger.exception(f"[B站] Cookie 提取失败: {e}")
return {}

View File

@@ -0,0 +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, 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)

View File

@@ -0,0 +1,30 @@
// Stealth script to prevent bot detection
(() => {
// Overwrite the `plugins` property to use a custom getter.
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
});
// Overwrite the `languages` property to use a custom getter.
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en'],
});
// Overwrite the `plugins` property to use a custom getter.
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5],
});
// Pass the Chrome Test.
window.chrome = {
runtime: {},
};
// Pass the Permissions Test.
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
})();

View File

@@ -0,0 +1,201 @@
"""
Xiaohongshu (小红书) 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
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 XiaohongshuUploader(BaseUploader):
"""Xiaohongshu video uploader using Playwright"""
# 超时配置 (秒)
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 120 # 发布检测超时
POLL_INTERVAL = 1 # 轮询间隔
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.xiaohongshu.com/publish/publish?from=homepage&target=video"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
try:
logger.info("[小红书] 正在设置定时发布时间...")
# Click "定时发布" label
label_element = page.locator("label: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('.el-input__inner[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 (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
)
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.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(str(self.file_path))
# 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(
'xpath=following-sibling::div[contains(@class, "preview-new")]'
)
if preview_new:
stage_elements = await preview_new.query_selector_all('div.stage')
upload_success = False
for stage in stage_elements:
text_content = await page.evaluate('(element) => element.textContent', stage)
if '上传成功' in text_content:
upload_success = True
break
if upload_success:
logger.info("[小红书] 检测到上传成功标识")
break
else:
logger.info("[小红书] 未找到上传成功标识,继续等待...")
else:
logger.info("[小红书] 未找到预览元素,继续等待...")
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)
logger.info("[小红书] 正在填充标题和话题...")
title_container = page.locator('div.plugin.title-container').locator('input.d-text')
if await title_container.count():
await title_container.fill(self.title[:30])
# Add tags
css_selector = ".tiptap"
for tag in self.tags:
await page.type(css_selector, "#" + tag)
await page.press(css_selector, "Space")
logger.info(f"[小红书] 总共添加 {len(self.tags)} 个话题")
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# 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()
else:
await page.locator('button:has-text("发布")').click()
await page.wait_for_url(
"https://creator.xiaohongshu.com/publish/success?**",
timeout=3000
)
logger.success("[小红书] 视频发布成功")
break
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)
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
}
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)

View File

@@ -18,3 +18,6 @@ python-dotenv>=1.0.0
loguru>=0.7.2
playwright>=1.40.0
requests>=2.31.0
# 社交媒体发布
biliup>=0.4.0

View File

@@ -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)
- **响应式**: 适配桌面端大屏操作

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -24,3 +24,66 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* 隐藏滚动条但保留滚动功能 */
html {
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE 和 Edge */
}
html::-webkit-scrollbar {
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;
}

View File

@@ -2,6 +2,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = typeof window !== 'undefined'
@@ -25,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>("");
@@ -40,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 = [
@@ -50,9 +61,10 @@ export default function Home() {
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
];
// 加载素材列表
// 加载素材列表和历史视频
useEffect(() => {
fetchMaterials();
fetchGeneratedVideos();
}, []);
const fetchMaterials = async () => {
@@ -86,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];
@@ -180,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);
@@ -197,13 +264,42 @@ export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
{/* Header <header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-4xl">🎬</span>
ViGent
</h1>
<div className="flex items-center gap-4">
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
视频生成
</span>
<Link
href="/publish"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
发布管理
</Link>
</div>
</div>
</header> */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-3xl">🎬</span>
<Link href="/" className="text-2xl font-bold text-white flex items-center gap-3 hover:opacity-80 transition-opacity">
<span className="text-4xl">🎬</span>
ViGent
</h1>
</Link>
<div className="flex items-center gap-4">
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</span>
<Link
href="/publish"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</Link>
</div>
</div>
</header>
@@ -290,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>
)}
@@ -424,25 +534,83 @@ export default function Home() {
</div>
{generatedVideo && (
<a
href={generatedVideo}
download
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
<>
<a
href={generatedVideo}
download
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
>
</a>
<Link
href="/publish"
className="mt-3 w-full py-3 rounded-xl bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
>
📤
</Link>
</>
)}
</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"
>
</a>
🔄
</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>
);
}

View File

@@ -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客户端使用当前域名
@@ -29,6 +32,11 @@ export default function PublishPage() {
const [tags, setTags] = useState<string>("");
const [isPublishing, setIsPublishing] = useState(false);
const [publishResults, setPublishResults] = useState<any[]>([]);
const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
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(() => {
@@ -48,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);
@@ -98,11 +104,20 @@ export default function PublishPage() {
title,
tags: tagList,
description: "",
publish_time: scheduleMode === "scheduled" && publishTime
? new Date(publishTime).toISOString()
: null
}),
});
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,
@@ -114,10 +129,79 @@ 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) => {
alert(
`登录功能需要在服务端执行。\n\n请在终端运行:\ncurl -X POST http://localhost:8006/api/publish/login/${platform}`
);
setIsLoadingQR(true);
setQrPlatform(platform); // 立即显示加载弹窗
setQrCodeImage(null); // 清空旧二维码
try {
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
method: 'POST'
});
const result = await res.json();
if (result.success && result.qr_code) {
setQrCodeImage(result.qr_code);
// 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}`);
}
};
const platformIcons: Record<string, string> = {
@@ -129,34 +213,61 @@ export default function PublishPage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900">
{/* QR码弹窗 */}
{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 min-w-[320px]">
<h2 className="text-2xl font-bold mb-4 text-center">🔐 {qrPlatform}</h2>
{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); setQrPlatform(null); }}
className="w-full mt-4 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
>
</button>
</div>
</div>
)}
{/* Header - 统一样式 */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="text-2xl font-bold text-white flex items-center gap-3 hover:opacity-80">
<span className="text-3xl">🎬</span>
TalkingHead Agent
<Link href="/" className="text-2xl font-bold text-white flex items-center gap-3 hover:opacity-80 transition-opacity">
<span className="text-4xl">🎬</span>
ViGent
</Link>
<nav className="flex gap-4">
<div className="flex items-center gap-4">
<Link
href="/"
className="px-4 py-2 text-gray-400 hover:text-white transition-colors"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</Link>
<Link
href="/publish"
className="px-4 py-2 text-white bg-purple-600 rounded-lg"
>
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</Link>
</nav>
</span>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">
<h1 className="text-3xl font-bold text-white mb-8">📤 </h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 账号管理 */}
<div className="space-y-6">
@@ -189,15 +300,31 @@ export default function PublishPage() {
</div>
</div>
</div>
<button
onClick={() => handleLogin(account.platform)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${account.logged_in
? "bg-gray-600 text-gray-300"
: "bg-purple-600 hover:bg-purple-700 text-white"
}`}
>
{account.logged_in ? "重新登录" : "登录"}
</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>
@@ -223,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}>
@@ -263,6 +390,40 @@ export default function PublishPage() {
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">
</label>
<div className="flex gap-3 mb-3">
<button
onClick={() => setScheduleMode("now")}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${scheduleMode === "now"
? "bg-purple-600 text-white"
: "bg-black/30 text-gray-400 hover:bg-black/50"
}`}
>
</button>
<button
onClick={() => setScheduleMode("scheduled")}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${scheduleMode === "scheduled"
? "bg-purple-600 text-white"
: "bg-black/30 text-gray-400 hover:bg-black/50"
}`}
>
</button>
</div>
{scheduleMode === "scheduled" && (
<input
type="datetime-local"
value={publishTime}
onChange={(e) => setPublishTime(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white"
/>
)}
</div>
</div>
</div>
@@ -325,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>