Compare commits

...

4 Commits

Author SHA1 Message Date
Kevin Wong
ad7ff7a385 界面优化 2026-01-22 11:14:42 +08:00
Kevin Wong
c7e2b4d363 文档更新 2026-01-22 09:54:32 +08:00
Kevin Wong
d5baa79448 文档更新 2026-01-22 09:52:29 +08:00
Kevin Wong
3db15cee4e 更新 2026-01-22 09:22:23 +08:00
24 changed files with 2782 additions and 169 deletions

View File

@@ -60,10 +60,10 @@ 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 浏览器社交发布需要)
playwright install chromium
```

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

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

View File

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

View File

@@ -2,8 +2,8 @@
**项目**ViGent2 数字人口播视频生成系统
**服务器**Dell R730 (2× RTX 3090 24GB)
**更新时间**2026-01-20
**整体进度**100%Day 6 LatentSync 1.6 升级完成)
**更新时间**2026-01-21
**整体进度**100%Day 7 社交发布完成)
## 📖 快速导航
@@ -16,7 +16,7 @@
| [时间线](#-时间线) | 开发历程 |
**相关文档**
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-6)
- [Day 日志](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/) (Day1-Day7)
- [部署指南](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,18 @@
- [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] 文档规则优化 (智能修改标准、工具使用规范)
---
## 🛤️ 后续规划
@@ -96,7 +109,7 @@
### 🔴 优先待办
- [x] 视频合成最终验证 (MP4生成) ✅ Day 4 完成
- [x] 端到端流程完整测试 ✅ Day 4 完成
- [ ] 社交媒体发布测试
- [ ] 社交媒体发布测试 (B站/抖音已登录)
### 🟠 功能完善
- [ ] 定时发布功能
@@ -126,7 +139,7 @@
| TTS 配音 | 100% | ✅ 完成 |
| 视频合成 | 100% | ✅ 完成 |
| 唇形同步 | 100% | ✅ LatentSync 1.6 升级完成 |
| 社交发布 | 80% | 🔄 框架完成,待测试 |
| 社交发布 | 100% | ✅ 完成 (待验证) |
| 服务器部署 | 100% | ✅ 完成 |
---
@@ -204,5 +217,12 @@ Day 6: LatentSync 1.6 升级 ✅ 完成
- 模型部署指南
- 服务器部署验证
- 性能优化 (视频预压缩、进度更新)
Day 7: 社交媒体发布完善 ✅ 完成
- QR码自动登录 (B站/抖音验证通过)
- 智能定位策略 (CSS/Text并行)
- 多平台发布 (B站/抖音/小红书)
- UI 一致性优化
- 文档规则体系优化
```

View File

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

View File

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

View File

@@ -1,19 +1,33 @@
from fastapi import APIRouter, UploadFile, File, HTTPException
from app.core.config import settings
import shutil
import uuid
import re
import time
from pathlib import Path
router = APIRouter()
def sanitize_filename(filename: str) -> str:
"""清理文件名,移除不安全字符"""
# 移除路径分隔符和特殊字符
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename)
# 限制长度
if len(safe_name) > 100:
ext = Path(safe_name).suffix
safe_name = safe_name[:100 - len(ext)] + ext
return safe_name
@router.post("/")
async def upload_material(file: UploadFile = File(...)):
if not file.filename.lower().endswith(('.mp4', '.mov', '.avi')):
raise HTTPException(400, "Invalid format")
file_id = str(uuid.uuid4())
ext = Path(file.filename).suffix
save_path = settings.UPLOAD_DIR / "materials" / f"{file_id}{ext}"
# 使用时间戳+原始文件名(保留原始名称,避免冲突)
timestamp = int(time.time())
safe_name = sanitize_filename(file.filename)
save_path = settings.UPLOAD_DIR / "materials" / f"{timestamp}_{safe_name}"
# Save file
with open(save_path, "wb") as buffer:
@@ -21,11 +35,14 @@ async def upload_material(file: UploadFile = File(...)):
# Calculate size
size_mb = save_path.stat().st_size / (1024 * 1024)
# 提取显示名称(去掉时间戳前缀)
display_name = safe_name
return {
"id": file_id,
"name": file.filename,
"path": f"uploads/materials/{file_id}{ext}",
"id": save_path.stem,
"name": display_name,
"path": f"uploads/materials/{save_path.name}",
"size_mb": size_mb,
"type": "video"
}
@@ -38,9 +55,16 @@ async def list_materials():
for f in materials_dir.glob("*"):
try:
stat = f.stat()
# 提取显示名称:去掉时间戳前缀 (格式: {timestamp}_{原始文件名})
display_name = f.name
if '_' in f.name:
parts = f.name.split('_', 1)
if parts[0].isdigit():
display_name = parts[1] # 原始文件名
files.append({
"id": f.stem,
"name": f.name,
"name": display_name,
"path": f"uploads/materials/{f.name}",
"size_mb": stat.st_size / (1024 * 1024),
"type": "video",
@@ -51,3 +75,26 @@ async def list_materials():
# Sort by creation time desc
files.sort(key=lambda x: x.get("created_at", 0), reverse=True)
return {"materials": files}
@router.delete("/{material_id}")
async def delete_material(material_id: str):
"""删除素材文件"""
materials_dir = settings.UPLOAD_DIR / "materials"
# 查找匹配的文件ID 是文件名不含扩展名)
found = None
for f in materials_dir.glob("*"):
if f.stem == material_id:
found = f
break
if not found:
raise HTTPException(404, "Material not found")
try:
found.unlink()
return {"success": True, "message": "素材已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

@@ -56,4 +56,36 @@ async def list_accounts():
@router.post("/login/{platform}")
async def login_platform(platform: str):
return await publish_service.login(platform)
result = await publish_service.login(platform)
if result.get("success"):
return result
else:
raise HTTPException(status_code=400, detail=result.get("message"))
@router.get("/login/status/{platform}")
async def get_login_status(platform: str):
"""检查登录状态"""
# 这里简化处理,实际应该维护一个登录会话字典
cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json"
if cookie_file.exists():
return {"success": True, "message": "已登录"}
else:
return {"success": False, "message": "未登录"}
@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的内容"}
"""
cookie_string = cookie_data.get("cookie_string", "")
result = await publish_service.save_cookie_string(platform, cookie_string)
if result.get("success"):
return result
else:
raise HTTPException(status_code=400, detail=result.get("message"))

View File

@@ -141,3 +141,58 @@ async def lipsync_health():
"""获取 LipSync 服务健康状态"""
lipsync = _get_lipsync_service()
return await lipsync.check_health()
@router.get("/generated")
async def list_generated_videos():
"""从文件系统读取生成的视频列表(持久化)"""
output_dir = settings.OUTPUT_DIR
videos = []
if output_dir.exists():
for f in output_dir.glob("*_output.mp4"):
try:
stat = f.stat()
videos.append({
"id": f.stem,
"name": f.name,
"path": f"/outputs/{f.name}",
"size_mb": stat.st_size / (1024 * 1024),
"created_at": stat.st_ctime
})
except Exception:
continue
# Sort by creation time desc (newest first)
videos.sort(key=lambda x: x.get("created_at", 0), reverse=True)
return {"videos": videos}
@router.delete("/generated/{video_id}")
async def delete_generated_video(video_id: str):
"""删除生成的视频"""
output_dir = settings.OUTPUT_DIR
# 查找匹配的文件
found = None
for f in output_dir.glob("*.mp4"):
if f.stem == video_id:
found = f
break
if not found:
raise HTTPException(404, "Video not found")
try:
found.unlink()
# 同时删除相关的临时文件(如果存在)
task_id = video_id.replace("_output", "")
for suffix in ["_audio.mp3", "_lipsync.mp4"]:
temp_file = output_dir / f"{task_id}{suffix}"
if temp_file.exists():
temp_file.unlink()
return {"success": True, "message": "视频已删除"}
except Exception as e:
raise HTTPException(500, f"删除失败: {str(e)}")

View File

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

View File

@@ -1,27 +1,35 @@
"""
发布服务 (Playwright)
发布服务 (基于 social-auto-upload 架构)
"""
from playwright.async_api import async_playwright
from datetime import datetime
from pathlib import Path
import json
import asyncio
from typing import Optional, List
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:
"""Social media publishing service"""
PLATFORMS = {
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame"},
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/"},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/"},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/"},
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/"},
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame"},
}
def __init__(self):
self.cookies_dir = settings.BASE_DIR / "cookies"
self.cookies_dir.mkdir(exist_ok=True)
def get_accounts(self):
"""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"
@@ -32,40 +40,178 @@ class PublishService:
"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
):
"""
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 / 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 / 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 / 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()
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": ""}
# 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):
"""
启动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)
# 启动登录并获取二维码
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 save_cookie_string(self, platform: str, cookie_string: str):
"""
保存从客户端浏览器提取的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
import json
with open(account_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
logger.success(f"[登录] {platform} Cookie已保存")
return {
"success": True,
"message": f"{self.PLATFORMS[platform]['name']} 登录成功"
}
except Exception as e:
logger.exception(f"[登录] Cookie保存失败: {e}")
return {
"success": False,
"message": f"Cookie保存失败: {str(e)}"
}

View File

@@ -0,0 +1,313 @@
"""
QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
import asyncio
import base64
from pathlib import Path
from playwright.async_api import async_playwright, Page
from loguru import logger
import json
import os
class QRLoginService:
"""QR码登录服务"""
def __init__(self, platform: str, cookies_dir: Path):
self.platform = platform
self.cookies_dir = cookies_dir
self.qr_code_image = None
self.login_success = False
self.cookies_data = 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):
"""
启动登录流程
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:
"""
提取二维码图片(并行执行 CSS策略 和 文本策略)
"""
async def strategy_css():
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
if el:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
return el
except:
pass
return None
async def strategy_text():
# 扩展支持 Bilibili 和 Douyin
if self.platform not in ["bilibili", "douyin"]: return None
try:
logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...")
# 关键词列表
keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"]
scan_text = None
# 遍历尝试关键词 (带等待)
for kw in keywords:
try:
t = page.get_by_text(kw, exact=False).first
# 稍微等待一下文字渲染
await t.wait_for(state="visible", timeout=2000)
scan_text = t
logger.debug(f"[{self.platform}] 找到关键词: {kw}")
break
except:
continue
if scan_text:
# 尝试定位周边的图片
parent_locator = scan_text
# 向上查找5层扩大范围
for _ in range(5):
parent_locator = parent_locator.locator("..")
# 找图片
img = parent_locator.locator("img").first
if await img.is_visible():
# 过滤掉头像等小图标,确保尺寸足够大
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Img)")
return img
# 找Canvas
canvas = parent_locator.locator("canvas").first
if await canvas.is_visible():
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Canvas)")
return canvas
except Exception as e:
logger.warning(f"[{self.platform}] 策略2异常: {e}")
return None
# 并行执行两个策略,谁先找到算谁的
tasks = [
asyncio.create_task(strategy_css()),
asyncio.create_task(strategy_text())
]
qr_element = None
pending = set(tasks)
while pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
for task in done:
result = await task
if result:
qr_element = result
break
if qr_element:
break
# 取消剩下的任务 (如果找到了)
for task in pending:
task.cancel()
if qr_element:
try:
screenshot = await qr_element.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}")
# 失败处理
logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图")
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
debug_dir.mkdir(exist_ok=True)
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
screenshot = await page.screenshot()
return base64.b64encode(screenshot).decode()
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(120):
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):
"""清理资源"""
if hasattr(self, 'context') and self.context:
try: await self.context.close()
except: pass
if hasattr(self, 'browser') and self.browser:
try: await self.browser.close()
except: pass
if hasattr(self, 'playwright') and self.playwright:
try: await self.playwright.stop()
except: pass
async def _save_cookies(self, cookies: list):
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
cookie_dict = {c['name']: c['value'] for c in cookies}
if self.platform == "bilibili":
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
logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
def get_login_status(self):
"""获取登录状态"""
return {
"success": self.login_success,
"cookies_saved": self.cookies_data is not None
}

View File

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

View File

@@ -0,0 +1,65 @@
"""
Base uploader class for all social media platforms
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List, Optional
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):
"""
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):
"""
Convert datetime to Unix timestamp
Args:
dt: datetime object or 0 for immediate publish
Returns:
int: Unix timestamp or 0
"""
if dt == 0:
return 0
return int(dt.timestamp())

View File

@@ -0,0 +1,123 @@
"""
Bilibili uploader using biliup library
"""
import json
from pathlib import Path
from typing import Optional, List
from datetime import datetime
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
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):
"""
Upload video to Bilibili
Returns:
dict: Upload result
"""
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)
# 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()
if ret.get('code') == 0:
bvid = ret.get('bvid', '')
logger.success(f"[B站] 上传成功: {bvid}")
return {
"success": True,
"message": "上传成功" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/{bvid}" if bvid else None
}
else:
error_msg = ret.get('message', '未知错误')
logger.error(f"[B站] 上传失败: {error_msg}")
return {
"success": False,
"message": f"上传失败: {error_msg}",
"url": None
}
except Exception as e:
logger.exception(f"[B站] 上传异常: {e}")
return {
"success": False,
"message": f"上传异常: {str(e)}",
"url": None
}

View File

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

View File

@@ -0,0 +1,169 @@
"""
Douyin (抖音) uploader using Playwright
Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
import asyncio
from playwright.async_api import Playwright, async_playwright
from loguru import logger
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
class DouyinUploader(BaseUploader):
"""Douyin video uploader using Playwright"""
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
try:
# Click "定时发布" radio button
label_element = page.locator("[class^='radio']:has-text('定时发布')")
await label_element.click()
await asyncio.sleep(1)
# Format time
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
# Fill datetime input
await page.locator('.semi-input[placeholder="日期和时间"]').click()
await page.keyboard.press("Control+KeyA")
await page.keyboard.type(str(publish_date_hour))
await page.keyboard.press("Enter")
await asyncio.sleep(1)
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
except Exception as e:
logger.error(f"[抖音] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright):
"""Main upload logic"""
try:
# Launch browser
browser = await playwright.chromium.launch(headless=False)
context = await browser.new_context(storage_state=self.account_file)
context = await set_init_script(context)
page = await context.new_page()
# Go to upload page
await page.goto(self.upload_url)
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
# Upload video file
await page.set_input_files("input[type='file']", str(self.file_path))
# Wait for redirect to publish page
while True:
try:
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page",
timeout=3000
)
logger.info("[抖音] 成功进入发布页面")
break
except:
try:
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
timeout=3000
)
logger.info("[抖音] 成功进入发布页面 (版本2)")
break
except:
await asyncio.sleep(0.5)
# Fill title
await asyncio.sleep(1)
logger.info("[抖音] 正在填充标题和话题...")
title_container = page.get_by_text('作品描述').locator("..").locator("..").locator(
"xpath=following-sibling::div[1]").locator("input")
if await title_container.count():
await title_container.fill(self.title[:30])
# Add tags
css_selector = ".zone-container"
for tag in self.tags:
await page.type(css_selector, "#" + tag)
await page.press(css_selector, "Space")
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
# Wait for upload to complete
while True:
try:
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
if number > 0:
logger.success("[抖音] 视频上传完毕")
break
else:
logger.info("[抖音] 正在上传视频中...")
await asyncio.sleep(2)
except:
await asyncio.sleep(2)
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
while True:
try:
publish_button = page.get_by_role('button', name="发布", exact=True)
if await publish_button.count():
await publish_button.click()
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/manage**",
timeout=3000
)
logger.success("[抖音] 视频发布成功")
break
except:
logger.info("[抖音] 视频正在发布中...")
await asyncio.sleep(0.5)
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[抖音] Cookie 更新完毕")
await asyncio.sleep(2)
await context.close()
await browser.close()
return {
"success": True,
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
"url": None
}
except Exception as e:
logger.exception(f"[抖音] 上传失败: {e}")
return {
"success": False,
"message": f"上传失败: {str(e)}",
"url": None
}
async def main(self):
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)

View File

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

View File

@@ -0,0 +1,172 @@
"""
Xiaohongshu (小红书) uploader using Playwright
Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
import asyncio
from playwright.async_api import Playwright, async_playwright
from loguru import logger
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
class XiaohongshuUploader(BaseUploader):
"""Xiaohongshu video uploader using Playwright"""
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.upload_url = "https://creator.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):
"""Main upload logic"""
try:
# Launch browser
browser = await playwright.chromium.launch(headless=False)
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
while True:
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(1)
except Exception as e:
logger.info(f"[小红书] 检测过程: {str(e)},重新尝试...")
await asyncio.sleep(0.5)
# 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
while True:
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:
logger.info("[小红书] 视频正在发布中...")
await asyncio.sleep(0.5)
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[小红书] Cookie 更新完毕")
await asyncio.sleep(2)
await context.close()
await browser.close()
return {
"success": True,
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
"url": None
}
except Exception as e:
logger.exception(f"[小红书] 上传失败: {e}")
return {
"success": False,
"message": f"上传失败: {str(e)}",
"url": None
}
async def main(self):
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)

View File

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

View File

@@ -1,36 +1,72 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# ViGent2 Frontend
## Getting Started
ViGent2 的前端界面,采用 Next.js 14 + TailwindCSS 构建。
First, run the development server:
## ✨ 核心功能
### 1. 视频生成 (`/`)
- **素材管理**: 拖拽上传人物视频,实时预览。
- **文案配音**: 集成 EdgeTTS支持多音色选择 (云溪 / 晓晓)。
- **进度追踪**: 实时显示视频生成进度 (10% -> 100%)。
- **结果预览**: 生成完成后直接播放下载。
### 2. 全自动发布 (`/publish`) [Day 7 新增]
- **多平台管理**: 统一管理 B站、抖音、小红书账号状态。
- **扫码登录**:
- 集成后端 Playwright 生成的 QR Code。
- 实时检测扫码状态 (Wait/Success)。
- Cookie 自动保存与状态同步。
- **发布配置**: 设置视频标题、标签、简介。
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
## 🛠️ 技术栈
- **框架**: Next.js 14 (App Router)
- **样式**: TailwindCSS
- **图标**: Lucide React
- **组件**: 自定义现代化组件 (Glassmorphism 风格)
- **API**: Fetch API (对接后端 FastAPI :8006)
## 🚀 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
默认运行在 **3002** 端口 (通过 `package.json` 配置):
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
# 访问: http://localhost:3002
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### 目录结构
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```
src/
├── app/
│ ├── page.tsx # 视频生成主页
│ ├── publish/ # 发布管理页
│ │ └── page.tsx
│ └── layout.tsx # 全局布局 (导航栏)
├── components/ # UI 组件
│ ├── VideoUploader.tsx # 视频上传
│ ├── StatusBadge.tsx # 状态徽章
│ └── ...
└── lib/ # 工具函数
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## 🔌 后端对接
## Learn More
- **Base URL**: `http://localhost:8006`
- **代理配置**: Next.js Rewrites (如需) 或直接 CORS。
To learn more about Next.js, take a look at the following resources:
## 🎨 设计规范
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- **主色调**: 深紫/黑色系 (Dark Mode)
- **交互**: 悬停微动画 (Hover Effects)
- **响应式**: 适配桌面端大屏操作

View File

@@ -24,3 +24,66 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* 隐藏滚动条但保留滚动功能 */
html {
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE 和 Edge */
}
html::-webkit-scrollbar {
display: none;
/* Chrome, Safari, Opera */
}
/* 自定义滚动条样式 - 深色主题 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(147, 51, 234, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.8);
}
/* 完全隐藏滚动条 */
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* 自定义 select 下拉菜单 */
.custom-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%239ca3af' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.custom-select option {
background: #1a1a2e;
color: white;
padding: 12px;
}

View File

@@ -2,6 +2,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = typeof window !== 'undefined'
@@ -25,6 +26,14 @@ interface Task {
download_url?: string;
}
interface GeneratedVideo {
id: string;
name: string;
path: string;
size_mb: number;
created_at: number;
}
export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
@@ -40,6 +49,8 @@ export default function Home() {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
// 可选音色
const voices = [
@@ -50,9 +61,10 @@ export default function Home() {
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
];
// 加载素材列表
// 加载素材列表和历史视频
useEffect(() => {
fetchMaterials();
fetchGeneratedVideos();
}, []);
const fetchMaterials = async () => {
@@ -86,6 +98,60 @@ export default function Home() {
}
};
// 获取已生成的视频列表(持久化)
const fetchGeneratedVideos = async () => {
try {
const res = await fetch(`${API_BASE}/api/videos/generated`);
if (res.ok) {
const data = await res.json();
setGeneratedVideos(data.videos || []);
}
} catch (error) {
console.error("获取历史视频失败:", error);
}
};
// 删除素材
const deleteMaterial = async (materialId: string) => {
if (!confirm("确定要删除这个素材吗?")) return;
try {
const res = await fetch(`${API_BASE}/api/materials/${materialId}`, {
method: "DELETE",
});
if (res.ok) {
fetchMaterials();
if (selectedMaterial === materialId) {
setSelectedMaterial("");
}
} else {
alert("删除失败");
}
} catch (error) {
alert("删除失败: " + error);
}
};
// 删除生成的视频
const deleteVideo = async (videoId: string) => {
if (!confirm("确定要删除这个视频吗?")) return;
try {
const res = await fetch(`${API_BASE}/api/videos/generated/${videoId}`, {
method: "DELETE",
});
if (res.ok) {
fetchGeneratedVideos();
if (selectedVideoId === videoId) {
setSelectedVideoId(null);
setGeneratedVideo(null);
}
} else {
alert("删除失败");
}
} catch (error) {
alert("删除失败: " + error);
}
};
// 上传视频
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -180,6 +246,7 @@ export default function Home() {
if (taskData.status === "completed") {
setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
setIsGenerating(false);
fetchGeneratedVideos(); // 刷新历史视频列表
} else if (taskData.status === "failed") {
alert("视频生成失败: " + taskData.message);
setIsGenerating(false);
@@ -197,13 +264,42 @@ export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
{/* Header <header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-4xl">🎬</span>
ViGent
</h1>
<div className="flex items-center gap-4">
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
视频生成
</span>
<Link
href="/publish"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
发布管理
</Link>
</div>
</div>
</header> */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-3xl">🎬</span>
<Link href="/" className="text-2xl font-bold text-white flex items-center gap-3 hover:opacity-80 transition-opacity">
<span className="text-4xl">🎬</span>
ViGent
</h1>
</Link>
<div className="flex items-center gap-4">
<span className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg font-semibold">
</span>
<Link
href="/publish"
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</Link>
</div>
</div>
</header>
@@ -290,21 +386,35 @@ export default function Home() {
) : (
<div className="grid grid-cols-2 gap-3">
{materials.map((m) => (
<button
<div
key={m.id}
onClick={() => setSelectedMaterial(m.id)}
className={`p-4 rounded-xl border-2 transition-all text-left ${selectedMaterial === m.id
className={`p-4 rounded-xl border-2 transition-all text-left relative group ${selectedMaterial === m.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<div className="text-white font-medium truncate">
{m.scene || m.name}
</div>
<div className="text-gray-400 text-sm mt-1">
{m.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={() => setSelectedMaterial(m.id)}
className="w-full text-left"
>
<div className="text-white font-medium truncate pr-6">
{m.scene || m.name}
</div>
<div className="text-gray-400 text-sm mt-1">
{m.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteMaterial(m.id);
}}
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="删除素材"
>
🗑
</button>
</div>
))}
</div>
)}
@@ -424,25 +534,83 @@ export default function Home() {
</div>
{generatedVideo && (
<a
href={generatedVideo}
download
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
<>
<a
href={generatedVideo}
download
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
>
</a>
<Link
href="/publish"
className="mt-3 w-full py-3 rounded-xl bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
>
📤
</Link>
</>
)}
</div>
{/* 历史视频列表 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
📂
</h2>
<button
onClick={fetchGeneratedVideos}
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
>
</a>
🔄
</button>
</div>
{generatedVideos.length === 0 ? (
<div className="text-center py-4 text-gray-500">
<p></p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto hide-scrollbar">
{generatedVideos.map((v) => (
<div
key={v.id}
className={`p-3 rounded-lg border transition-all flex items-center justify-between group ${selectedVideoId === v.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<button
onClick={() => {
setSelectedVideoId(v.id);
setGeneratedVideo(`${API_BASE}${v.path}`);
}}
className="flex-1 text-left"
>
<div className="text-white text-sm truncate">
{new Date(v.created_at * 1000).toLocaleString('zh-CN')}
</div>
<div className="text-gray-400 text-xs">
{v.size_mb.toFixed(1)} MB
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteVideo(v.id);
}}
className="p-1 text-gray-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="删除视频"
>
🗑
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="border-t border-white/10 mt-12">
<div className="max-w-6xl mx-auto px-6 py-4 text-center text-gray-500 text-sm">
ViGent - MuseTalk + EdgeTTS
</div>
</footer>
</div>
);
}

View File

@@ -29,6 +29,10 @@ 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);
// 加载账号和视频列表
useEffect(() => {
@@ -48,20 +52,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,6 +100,9 @@ export default function PublishPage() {
title,
tags: tagList,
description: "",
publish_time: scheduleMode === "scheduled" && publishTime
? new Date(publishTime).toISOString()
: null
}),
});
@@ -115,9 +120,45 @@ export default function PublishPage() {
};
const handleLogin = async (platform: string) => {
alert(
`登录功能需要在服务端执行。\n\n请在终端运行:\ncurl -X POST http://localhost:8006/api/publish/login/${platform}`
);
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);
setQrPlatform(platform);
// 轮询登录状态
const checkInterval = setInterval(async () => {
const statusRes = await fetch(`${API_BASE}/api/publish/login/status/${platform}`);
const statusData = await statusRes.json();
if (statusData.success) {
clearInterval(checkInterval);
setQrCodeImage(null);
setQrPlatform(null);
alert('✅ 登录成功!');
fetchAccounts(); // 刷新账号状态
}
}, 2000); // 每2秒检查一次
// 2分钟后停止轮询
setTimeout(() => {
clearInterval(checkInterval);
if (qrCodeImage) {
setQrCodeImage(null);
alert('登录超时,请重试');
}
}, 120000);
} else {
alert(result.message || '登录失败');
}
} catch (error) {
alert(`登录失败: ${error}`);
}
};
const platformIcons: Record<string, string> = {
@@ -129,34 +170,52 @@ 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码弹窗 */}
{qrCodeImage && (
<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">
<h2 className="text-2xl font-bold mb-4 text-center">🔐 {qrPlatform}</h2>
<img
src={`data:image/png;base64,${qrCodeImage}`}
alt="QR Code"
className="w-full h-auto"
/>
<p className="text-center text-gray-600 mt-4">
使
</p>
<button
onClick={() => setQrCodeImage(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">
@@ -191,12 +250,9 @@ export default function PublishPage() {
</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"
}`}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
>
{account.logged_in ? "重新登录" : "登录"}
🔐
</button>
</div>
))}
@@ -223,7 +279,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 +319,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>