Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3df41904 | ||
|
|
561d74e16d | ||
|
|
cfe21d8337 | ||
|
|
3a76f9d0cf | ||
|
|
ad7ff7a385 | ||
|
|
c7e2b4d363 | ||
|
|
d5baa79448 | ||
|
|
3db15cee4e |
@@ -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
535
Docs/DevLogs/Day7.md
Normal 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
113
Docs/DevLogs/Day8.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Day 8: 用户体验优化
|
||||
|
||||
**日期**: 2026-01-22
|
||||
**目标**: 文件名保留 + 视频持久化 + 界面优化
|
||||
|
||||
---
|
||||
|
||||
## 📋 任务概览
|
||||
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| 文件名保留 | ✅ 完成 |
|
||||
| 视频持久化 | ✅ 完成 |
|
||||
| 历史视频列表 | ✅ 完成 |
|
||||
| 删除功能 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 实施成果
|
||||
|
||||
### 后端改动
|
||||
|
||||
**修改文件**:
|
||||
- `backend/app/api/materials.py`
|
||||
- ✅ `sanitize_filename()` 文件名安全化
|
||||
- ✅ 时间戳前缀避免冲突 (`{timestamp}_{原始文件名}`)
|
||||
- ✅ `list_materials` 显示原始文件名
|
||||
- ✅ `DELETE /api/materials/{id}` 删除素材
|
||||
|
||||
- `backend/app/api/videos.py`
|
||||
- ✅ `GET /api/videos/generated` 历史视频列表
|
||||
- ✅ `DELETE /api/videos/generated/{id}` 删除视频
|
||||
|
||||
### 前端改动
|
||||
|
||||
**修改文件**:
|
||||
- `frontend/src/app/page.tsx`
|
||||
- ✅ `GeneratedVideo` 类型定义
|
||||
- ✅ `generatedVideos` 状态管理
|
||||
- ✅ `fetchGeneratedVideos()` 获取历史
|
||||
- ✅ `deleteMaterial()` / `deleteVideo()` 删除功能
|
||||
- ✅ 素材卡片添加删除按钮 (hover 显示)
|
||||
- ✅ 历史视频列表组件 (右侧预览区下方)
|
||||
- ✅ 生成完成后自动刷新历史列表
|
||||
|
||||
---
|
||||
|
||||
## 🔧 API 变更
|
||||
|
||||
### 新增端点
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/videos/generated` | 获取生成视频列表 |
|
||||
| DELETE | `/api/videos/generated/{id}` | 删除生成视频 |
|
||||
| DELETE | `/api/materials/{id}` | 删除素材 |
|
||||
|
||||
### 文件命名规则
|
||||
|
||||
```
|
||||
原始: 测试视频.mp4
|
||||
保存: 1737518400_测试视频.mp4
|
||||
显示: 测试视频.mp4 (前端自动去除时间戳前缀)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成总结
|
||||
|
||||
1. **文件名保留** - 上传保留原始名称,时间戳前缀避免冲突
|
||||
2. **视频持久化** - 从文件系统读取,刷新不丢失
|
||||
3. **历史列表** - 右侧显示历史视频,点击切换播放
|
||||
4. **删除功能** - 素材和视频均支持删除
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试清单
|
||||
|
||||
- [x] 上传视频后检查素材列表显示原始文件名
|
||||
- [x] 刷新页面后检查历史视频列表持久化
|
||||
- [x] 测试删除素材功能
|
||||
- [x] 测试删除生成视频功能
|
||||
- [x] 测试历史视频列表点击切换播放
|
||||
|
||||
---
|
||||
|
||||
## 🔧 发布功能修复 (Day 8 下半场)
|
||||
|
||||
> 以下修复在用户体验优化后进行
|
||||
|
||||
### 问题
|
||||
|
||||
1. **抖音 QR 登录假成功** - 前端检测到旧 Cookie 文件就显示"登录成功",实际可能已过期
|
||||
2. **抖音上传循环卡死** - 发布后检测逻辑不完善,`while True` 无超时
|
||||
3. **前端轮询不规范** - 使用 `setInterval` 手动轮询,不符合 React 最佳实践
|
||||
|
||||
### 修复
|
||||
|
||||
**后端**:
|
||||
- `publish_service.py` - 添加 `logout()` 方法、修复 `get_login_session_status()` 优先检查活跃会话
|
||||
- `api/publish.py` - 新增 `POST /api/publish/logout/{platform}` 端点
|
||||
- `douyin_uploader.py` - 添加 `import time`,修复发布按钮点击竞态条件
|
||||
|
||||
**前端**:
|
||||
- `publish/page.tsx` - 使用 `useSWR` 替代 `setInterval` 轮询登录状态
|
||||
- `package.json` - 添加 `swr` 依赖
|
||||
|
||||
### 新增 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/publish/logout/{platform}` | 注销平台登录 |
|
||||
|
||||
119
Docs/DevLogs/Day9.md
Normal file
119
Docs/DevLogs/Day9.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Day 9: 发布模块代码优化
|
||||
|
||||
**日期**: 2026-01-23
|
||||
**目标**: 代码质量优化 + 发布功能验证
|
||||
|
||||
---
|
||||
|
||||
## 📋 任务概览
|
||||
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| B站/抖音发布验证 | ✅ 完成 |
|
||||
| 资源清理保障 (try-finally) | ✅ 完成 |
|
||||
| 超时保护 (消除无限循环) | ✅ 完成 |
|
||||
| 小红书 headless 模式修复 | ✅ 完成 |
|
||||
| API 输入验证 | ✅ 完成 |
|
||||
| 类型提示完善 | ✅ 完成 |
|
||||
| 服务层代码优化 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 发布验证结果
|
||||
|
||||
### 登录功能
|
||||
- ✅ **B站登录成功** - 策略3(Text)匹配,Cookie已保存
|
||||
- ✅ **抖音登录成功** - 策略3(Text)匹配,Cookie已保存
|
||||
|
||||
### 发布功能
|
||||
- ✅ **抖音发布成功** - 自动关闭弹窗、跳转管理页面
|
||||
- ✅ **B站发布成功** - API返回 `bvid: BV14izPBQEbd`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 代码优化
|
||||
|
||||
### 1. 资源清理保障
|
||||
|
||||
**问题**:Playwright 浏览器在异常路径可能未关闭
|
||||
|
||||
**修复**:`try-finally` 模式确保资源释放
|
||||
```python
|
||||
browser = None
|
||||
context = None
|
||||
try:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context(...)
|
||||
# ... 业务逻辑 ...
|
||||
finally:
|
||||
if context:
|
||||
try: await context.close()
|
||||
except Exception: pass
|
||||
if browser:
|
||||
try: await browser.close()
|
||||
except Exception: pass
|
||||
```
|
||||
|
||||
### 2. 超时保护
|
||||
|
||||
**问题**:`while True` 循环可能导致任务卡死
|
||||
|
||||
**修复**:添加类级别超时常量
|
||||
```python
|
||||
class DouyinUploader(BaseUploader):
|
||||
UPLOAD_TIMEOUT = 300 # 视频上传超时
|
||||
PUBLISH_TIMEOUT = 180 # 发布检测超时
|
||||
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
|
||||
```
|
||||
|
||||
### 3. B站 bvid 提取修复
|
||||
|
||||
**问题**:API 返回的 bvid 在 `data` 字段内
|
||||
|
||||
**修复**:同时检查多个位置
|
||||
```python
|
||||
bvid = ret.get('data', {}).get('bvid') or ret.get('bvid', '')
|
||||
aid = ret.get('data', {}).get('aid') or ret.get('aid', '')
|
||||
```
|
||||
|
||||
### 4. API 输入验证
|
||||
|
||||
**修复**:所有端点添加平台验证
|
||||
```python
|
||||
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
|
||||
|
||||
if platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 修改文件列表
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `app/api/publish.py` | 输入验证、平台常量、文档改进 |
|
||||
| `app/services/publish_service.py` | 类型提示、平台 enabled 标记 |
|
||||
| `app/services/qr_login_service.py` | 类型提示、修复裸 except、超时常量 |
|
||||
| `app/services/uploader/base_uploader.py` | 类型提示 |
|
||||
| `app/services/uploader/bilibili_uploader.py` | bvid提取修复、类型提示 |
|
||||
| `app/services/uploader/douyin_uploader.py` | 资源清理、超时保护、类型提示 |
|
||||
| `app/services/uploader/xiaohongshu_uploader.py` | headless模式、资源清理、超时保护 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成总结
|
||||
|
||||
1. **发布功能验证通过** - B站/抖音登录和发布均正常
|
||||
2. **代码健壮性提升** - 资源清理、超时保护、异常处理
|
||||
3. **代码可维护性** - 完整类型提示、常量化配置
|
||||
4. **服务器兼容性** - 小红书 headless 模式修复
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [代码审核报告](file:///C:/Users/danny/.gemini/antigravity/brain/a28bb1a6-2929-4c55-b837-c989943844e1/walkthrough.md)
|
||||
- [部署手册](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 后端 (FastAPI) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Celery 任务队列 (Redis) │
|
||||
│ 异步任务队列 (asyncio) │
|
||||
│ ├── 视频生成任务 │
|
||||
│ ├── TTS 配音任务 │
|
||||
│ └── 自动发布任务 │
|
||||
@@ -30,7 +30,7 @@
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ MuseTalk │ │ FFmpeg │ │Playwright│
|
||||
│LatentSync│ │ FFmpeg │ │Playwright│
|
||||
│ 唇形同步 │ │ 视频合成 │ │ 自动发布 │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
@@ -45,7 +45,7 @@
|
||||
| **UI 组件库** | Tailwind + shadcn/ui | Ant Design |
|
||||
| **后端框架** | FastAPI | Flask |
|
||||
| **任务队列** | Celery + Redis | RQ / Dramatiq |
|
||||
| **唇形同步** | MuseTalk | Wav2Lip / SadTalker |
|
||||
| **唇形同步** | **LatentSync 1.6** | MuseTalk / Wav2Lip |
|
||||
| **TTS 配音** | EdgeTTS | CosyVoice |
|
||||
| **声音克隆** | GPT-SoVITS (可选) | - |
|
||||
| **视频处理** | FFmpeg | MoviePy |
|
||||
@@ -269,6 +269,32 @@ cp -r SuperIPAgent/social-auto-upload backend/social_upload
|
||||
- [x] **常驻模型服务** (Persistent Server, 0s 加载)
|
||||
- [x] **GPU 并发控制** (串行队列防崩溃)
|
||||
|
||||
### 阶段十一:社交媒体发布完善 (Day 7) ✅
|
||||
|
||||
> **目标**:实现全自动扫码登录和多平台发布
|
||||
|
||||
- [x] QR码自动登录 (Playwright headless + Stealth)
|
||||
- [x] 多平台上传器架构 (B站/抖音/小红书)
|
||||
- [x] Cookie 自动管理
|
||||
- [x] 定时发布功能
|
||||
|
||||
### 阶段十二:用户体验优化 (Day 8) ✅
|
||||
|
||||
> **目标**:提升文件管理和历史记录功能
|
||||
|
||||
- [x] 文件名保留 (时间戳前缀 + 原始名称)
|
||||
- [x] 视频持久化 (历史视频列表 API)
|
||||
- [x] 素材/视频删除功能
|
||||
|
||||
### 阶段十三:发布模块优化 (Day 9) ✅
|
||||
|
||||
> **目标**:代码质量优化 + 发布功能验证
|
||||
|
||||
- [x] B站/抖音登录+发布验证通过
|
||||
- [x] 资源清理保障 (try-finally)
|
||||
- [x] 超时保护 (消除无限循环)
|
||||
- [x] 完整类型提示
|
||||
|
||||
---
|
||||
|
||||
## 项目目录结构 (最终)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**项目**:ViGent2 数字人口播视频生成系统
|
||||
**服务器**:Dell R730 (2× RTX 3090 24GB)
|
||||
**更新时间**:2026-01-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 模式修复
|
||||
- 完整类型提示
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 🎬 **唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Diffusion 模型
|
||||
- 🎙️ **TTS 配音** - EdgeTTS 多音色支持(云溪、晓晓等)
|
||||
- 📱 **一键发布** - Playwright 自动发布到抖音、小红书、B站等
|
||||
- 📱 **全自动发布** - 扫码登录 + Cookie持久化,支持多平台(B站/抖音/小红书)定时发布
|
||||
- 🖥️ **Web UI** - Next.js 现代化界面
|
||||
- 🚀 **性能优化** - 视频预压缩、常驻模型服务 (0s加载)
|
||||
|
||||
|
||||
221
backend/app/api/login_helper.py
Normal file
221
backend/app/api/login_helper.py
Normal 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)
|
||||
@@ -1,19 +1,33 @@
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from app.core.config import settings
|
||||
import shutil
|
||||
import uuid
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名,移除不安全字符"""
|
||||
# 移除路径分隔符和特殊字符
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# 限制长度
|
||||
if len(safe_name) > 100:
|
||||
ext = Path(safe_name).suffix
|
||||
safe_name = safe_name[:100 - len(ext)] + ext
|
||||
return safe_name
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def upload_material(file: UploadFile = File(...)):
|
||||
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
|
||||
raise HTTPException(400, "Invalid format")
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
ext = Path(file.filename).suffix
|
||||
save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}"
|
||||
# 使用时间戳+原始文件名(保留原始名称,避免冲突)
|
||||
timestamp = int(time.time())
|
||||
safe_name = sanitize_filename(file.filename)
|
||||
save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}"
|
||||
|
||||
# Save file
|
||||
with open(save_path, "wb") as buffer:
|
||||
@@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)):
|
||||
|
||||
# Calculate size
|
||||
size_mb = save_path.stat().st_size / (1024 * 1024)
|
||||
|
||||
# 提取显示名称(去掉时间戳前缀)
|
||||
display_name = safe_name
|
||||
|
||||
return {
|
||||
"id": file_id,
|
||||
"name": file.filename,
|
||||
"path": f"uploads/materials/{file_id}{ext}",
|
||||
"id": save_path.stem,
|
||||
"name": display_name,
|
||||
"path": f"uploads/materials/{save_path.name}",
|
||||
"size_mb": size_mb,
|
||||
"type": "video"
|
||||
}
|
||||
@@ -38,9 +55,16 @@ async def list_materials():
|
||||
for f in materials_dir.glob("*"):
|
||||
try:
|
||||
stat = f.stat()
|
||||
# 提取显示名称:去掉时间戳前缀 (格式: {timestamp}_{原始文件名})
|
||||
display_name = f.name
|
||||
if '_' in f.name:
|
||||
parts = f.name.split('_', 1)
|
||||
if parts[0].isdigit():
|
||||
display_name = parts[1] # 原始文件名
|
||||
|
||||
files.append({
|
||||
"id": f.stem,
|
||||
"name": f.name,
|
||||
"name": display_name,
|
||||
"path": f"uploads/materials/{f.name}",
|
||||
"size_mb": stat.st_size / (1024 * 1024),
|
||||
"type": "video",
|
||||
@@ -51,3 +75,26 @@ async def list_materials():
|
||||
# Sort by creation time desc
|
||||
files.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
||||
return {"materials": files}
|
||||
|
||||
|
||||
@router.delete("/{material_id}")
|
||||
async def delete_material(material_id: str):
|
||||
"""删除素材文件"""
|
||||
materials_dir = settings.UPLOAD_DIR / "materials"
|
||||
|
||||
# 查找匹配的文件(ID 是文件名不含扩展名)
|
||||
found = None
|
||||
for f in materials_dir.glob("*"):
|
||||
if f.stem == material_id:
|
||||
found = f
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(404, "Material not found")
|
||||
|
||||
try:
|
||||
found.unlink()
|
||||
return {"success": True, "message": "素材已删除"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"删除失败: {str(e)}")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ router = APIRouter()
|
||||
publish_service = PublishService()
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
"""Video publish request model"""
|
||||
video_path: str
|
||||
platform: str
|
||||
title: str
|
||||
@@ -20,13 +21,25 @@ class PublishRequest(BaseModel):
|
||||
publish_time: Optional[datetime] = None
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
"""Video publish response model"""
|
||||
success: bool
|
||||
message: str
|
||||
platform: str
|
||||
url: Optional[str] = None
|
||||
|
||||
# Supported platforms for validation
|
||||
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
|
||||
|
||||
@router.post("/", response_model=PublishResponse)
|
||||
async def publish_video(request: PublishRequest, background_tasks: BackgroundTasks):
|
||||
"""发布视频到指定平台"""
|
||||
# Validate platform
|
||||
if request.platform not in SUPPORTED_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的平台: {request.platform}。支持的平台: {', '.join(SUPPORTED_PLATFORMS)}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await publish_service.publish(
|
||||
video_path=request.video_path,
|
||||
@@ -56,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"))
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)}"
|
||||
}
|
||||
|
||||
344
backend/app/services/qr_login_service.py
Normal file
344
backend/app/services/qr_login_service.py
Normal 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
|
||||
}
|
||||
9
backend/app/services/uploader/__init__.py
Normal file
9
backend/app/services/uploader/__init__.py
Normal 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']
|
||||
65
backend/app/services/uploader/base_uploader.py
Normal file
65
backend/app/services/uploader/base_uploader.py
Normal 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())
|
||||
172
backend/app/services/uploader/bilibili_uploader.py
Normal file
172
backend/app/services/uploader/bilibili_uploader.py
Normal 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
|
||||
}
|
||||
107
backend/app/services/uploader/cookie_utils.py
Normal file
107
backend/app/services/uploader/cookie_utils.py
Normal 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 {}
|
||||
585
backend/app/services/uploader/douyin_uploader.py
Normal file
585
backend/app/services/uploader/douyin_uploader.py
Normal 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)
|
||||
30
backend/app/services/uploader/stealth.min.js
vendored
Normal file
30
backend/app/services/uploader/stealth.min.js
vendored
Normal 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)
|
||||
);
|
||||
})();
|
||||
201
backend/app/services/uploader/xiaohongshu_uploader.py
Normal file
201
backend/app/services/uploader/xiaohongshu_uploader.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# ViGent2 Frontend
|
||||
|
||||
## Getting Started
|
||||
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
|
||||
|
||||
First, run the development server:
|
||||
## ✨ 核心功能
|
||||
|
||||
### 1. 视频生成 (`/`)
|
||||
- **素材管理**: 拖拽上传人物视频,实时预览。
|
||||
- **文案配音**: 集成 EdgeTTS,支持多音色选择 (云溪 / 晓晓)。
|
||||
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
|
||||
- **结果预览**: 生成完成后直接播放下载。
|
||||
|
||||
### 2. 全自动发布 (`/publish`) [Day 7 新增]
|
||||
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
|
||||
- **扫码登录**:
|
||||
- 集成后端 Playwright 生成的 QR Code。
|
||||
- 实时检测扫码状态 (Wait/Success)。
|
||||
- Cookie 自动保存与状态同步。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **框架**: Next.js 14 (App Router)
|
||||
- **样式**: TailwindCSS
|
||||
- **图标**: Lucide React
|
||||
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
|
||||
- **API**: Fetch API (对接后端 FastAPI :8006)
|
||||
|
||||
## 🚀 开发指南
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
默认运行在 **3002** 端口 (通过 `package.json` 配置):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
# 访问: http://localhost:3002
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### 目录结构
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── page.tsx # 视频生成主页
|
||||
│ ├── publish/ # 发布管理页
|
||||
│ │ └── page.tsx
|
||||
│ └── layout.tsx # 全局布局 (导航栏)
|
||||
├── components/ # UI 组件
|
||||
│ ├── VideoUploader.tsx # 视频上传
|
||||
│ ├── StatusBadge.tsx # 状态徽章
|
||||
│ └── ...
|
||||
└── lib/ # 工具函数
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## 🔌 后端对接
|
||||
|
||||
## Learn More
|
||||
- **Base URL**: `http://localhost:8006`
|
||||
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## 🎨 设计规范
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- **主色调**: 深紫/黑色系 (Dark Mode)
|
||||
- **交互**: 悬停微动画 (Hover Effects)
|
||||
- **响应式**: 适配桌面端大屏操作
|
||||
|
||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -2759,6 +2760,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -6027,6 +6037,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
|
||||
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
@@ -6387,6 +6410,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"swr": "^2.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -23,4 +24,4 @@
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user