更新
This commit is contained in:
@@ -112,10 +112,23 @@ backend/
|
||||
- `REDIS_URL`
|
||||
- `GLM_API_KEY`
|
||||
- `LATENTSYNC_*`
|
||||
- `WEIXIN_HEADLESS_MODE` (headful/headless-new)
|
||||
- `WEIXIN_CHROME_PATH` / `WEIXIN_BROWSER_CHANNEL`
|
||||
- `WEIXIN_USER_AGENT` / `WEIXIN_LOCALE` / `WEIXIN_TIMEZONE_ID`
|
||||
- `WEIXIN_FORCE_SWIFTSHADER`
|
||||
- `WEIXIN_TRANSCODE_MODE` (reencode/faststart/off)
|
||||
|
||||
---
|
||||
|
||||
## 10. 最小新增模块示例
|
||||
## 10. Playwright 发布调试
|
||||
|
||||
- 诊断日志落盘:`backend/app/debug_screenshots/weixin_network.log` / `douyin_network.log`
|
||||
- 关键失败截图:`backend/app/debug_screenshots/weixin_*.png` / `douyin_*.png`
|
||||
- 视频号建议使用 headful + xvfb-run(避免 headless 解码/指纹问题)
|
||||
|
||||
---
|
||||
|
||||
## 11. 最小新增模块示例
|
||||
|
||||
```
|
||||
app/modules/foo/
|
||||
|
||||
@@ -54,7 +54,9 @@ backend/
|
||||
* `PUT /api/materials/{material_id}`: 重命名素材
|
||||
|
||||
4. **社交发布 (Publish)**
|
||||
* `POST /api/publish`: 发布视频到 B站/抖音/小红书
|
||||
* `POST /api/publish`: 发布视频到 抖音/微信视频号/B站/小红书
|
||||
|
||||
> 提示:视频号发布建议使用 headful + xvfb-run 运行后端,避免 headless 解码失败。
|
||||
|
||||
5. **资源库 (Assets)**
|
||||
* `GET /api/assets/subtitle-styles`: 字幕样式列表
|
||||
|
||||
@@ -25,8 +25,14 @@ python3 --version
|
||||
# 检查 Node.js 版本 (需要 18+)
|
||||
node --version
|
||||
|
||||
# 检查 FFmpeg
|
||||
ffmpeg -version
|
||||
# 检查 FFmpeg
|
||||
ffmpeg -version
|
||||
|
||||
# 检查 Chrome (视频号发布)
|
||||
google-chrome --version
|
||||
|
||||
# 检查 Xvfb
|
||||
xvfb-run --help
|
||||
|
||||
# 检查 pm2 (用于服务管理)
|
||||
pm2 --version
|
||||
@@ -37,11 +43,20 @@ redis-server --version
|
||||
|
||||
如果缺少依赖:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
sudo apt update
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# 安装 Xvfb (视频号发布)
|
||||
sudo apt install xvfb
|
||||
|
||||
# 安装 pm2
|
||||
npm install -g pm2
|
||||
# 安装 pm2
|
||||
npm install -g pm2
|
||||
|
||||
# 安装 Chrome (视频号发布)
|
||||
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-linux-signing-keyring.gpg
|
||||
printf "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main\n" | sudo tee /etc/apt/sources.list.d/google-chrome.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y google-chrome-stable
|
||||
```
|
||||
|
||||
---
|
||||
@@ -95,9 +110,11 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or
|
||||
# 安装 Python 依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装 Playwright 浏览器(社交发布需要)
|
||||
playwright install chromium
|
||||
```
|
||||
# 安装 Playwright 浏览器(社交发布需要)
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
> 提示:视频号发布建议使用系统 Chrome + xvfb-run(避免 headless 解码失败)。
|
||||
|
||||
---
|
||||
|
||||
@@ -164,6 +181,14 @@ cp .env.example .env
|
||||
| `LATENTSYNC_GUIDANCE_SCALE` | 1.5 | 引导系数 (1.0-3.0) |
|
||||
| `DEBUG` | true | 生产环境改为 false |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | 任务状态存储(不可用时回退内存) |
|
||||
| `WEIXIN_HEADLESS_MODE` | headless-new | 视频号 Playwright 模式 (headful/headless-new) |
|
||||
| `WEIXIN_CHROME_PATH` | `/usr/bin/google-chrome` | 系统 Chrome 路径 |
|
||||
| `WEIXIN_BROWSER_CHANNEL` | | Chromium 通道 (可选) |
|
||||
| `WEIXIN_USER_AGENT` | Chrome 120 UA | 视频号浏览器指纹 UA |
|
||||
| `WEIXIN_LOCALE` | zh-CN | 视频号语言环境 |
|
||||
| `WEIXIN_TIMEZONE_ID` | Asia/Shanghai | 视频号时区 |
|
||||
| `WEIXIN_FORCE_SWIFTSHADER` | true | 强制软件 WebGL,避免 context lost |
|
||||
| `WEIXIN_TRANSCODE_MODE` | reencode | 上传前转码 (reencode/faststart/off) |
|
||||
|
||||
---
|
||||
|
||||
@@ -185,13 +210,19 @@ npm run build
|
||||
|
||||
> 💡 先手动启动测试,确认一切正常后再配置 pm2 常驻服务。
|
||||
|
||||
### 启动后端 (终端 1)
|
||||
### 启动后端 (终端 1)
|
||||
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
```
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
```
|
||||
|
||||
推荐使用项目脚本启动后端(已内置 xvfb + headful 发布环境):
|
||||
```bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2
|
||||
./run_backend.sh # 默认 8006,可用 PORT 覆盖
|
||||
```
|
||||
|
||||
### 启动前端 (终端 2)
|
||||
|
||||
@@ -224,20 +255,29 @@ python -m scripts.server
|
||||
|
||||
建议使用 Shell 脚本启动以避免环境问题。
|
||||
|
||||
1. 创建启动脚本 `run_backend.sh`:
|
||||
```bash
|
||||
cat > run_backend.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
cd /home/rongye/ProgramFiles/ViGent2/backend
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
EOF
|
||||
chmod +x run_backend.sh
|
||||
```
|
||||
1. 创建启动脚本 `run_backend.sh`:
|
||||
```bash
|
||||
cat > run_backend.sh << 'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export WEIXIN_HEADLESS_MODE=headful
|
||||
export WEIXIN_DEBUG_ARTIFACTS=false
|
||||
export WEIXIN_RECORD_VIDEO=false
|
||||
export DOUYIN_DEBUG_ARTIFACTS=false
|
||||
export DOUYIN_RECORD_VIDEO=false
|
||||
PORT=${PORT:-8006}
|
||||
cd "$BASE_DIR/backend"
|
||||
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
|
||||
EOF
|
||||
chmod +x run_backend.sh
|
||||
```
|
||||
|
||||
2. 使用 pm2 启动:
|
||||
```bash
|
||||
pm2 start ./run_backend.sh --name vigent2-backend
|
||||
```
|
||||
2. 使用 pm2 启动:
|
||||
```bash
|
||||
pm2 start ./run_backend.sh --name vigent2-backend
|
||||
```
|
||||
|
||||
### 2. 启动前端服务 (Next.js)
|
||||
|
||||
|
||||
@@ -107,3 +107,62 @@
|
||||
- `frontend/src/components/VideoPreviewModal.tsx`
|
||||
- `frontend/src/features/home/ui/PreviewPanel.tsx`
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 📹 微信视频号发布接入 (16:30)
|
||||
|
||||
### 内容
|
||||
- 新增视频号上传器 `WeixinUploader`,打通上传/标题/简介/标签/发布流程
|
||||
- 视频号扫码登录配置完善(iframe 扫码、候选二维码过滤)
|
||||
- 发布平台与路由接入视频号
|
||||
- 中文错误提示 + 关键节点截图保存到 `debug_screenshots`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/qr_login_service.py`
|
||||
- `backend/app/services/publish_service.py`
|
||||
- `backend/app/modules/publish/router.py`
|
||||
- `backend/app/modules/login_helper/router.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 视频号上传稳定性修复 (17:40)
|
||||
|
||||
### 内容
|
||||
- 统一浏览器指纹(UA/locale/timezone)并支持系统 Chrome
|
||||
- 增加 headful + xvfb-run 运行方案,避免 headless 检测与解码失败
|
||||
- 强制 SwiftShader,修复 WebGL context loss
|
||||
- 上传前转码为兼容 MP4(H.264 + AAC + faststart)
|
||||
- 增强上传状态判断与调试日志 `weixin_network.log`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/qr_login_service.py`
|
||||
- `run_backend.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 发布诊断增强 (18:10)
|
||||
|
||||
### 内容
|
||||
- 抖音发布新增网络日志与失败截图,便于定位上传/发布失败
|
||||
- 视频号上传失败截图与网络日志落盘
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/debug_screenshots/*`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 发布页交互调整 (18:20)
|
||||
|
||||
### 内容
|
||||
- 未选择平台时禁用发布按钮
|
||||
- 移除定时发布 UI/参数,仅保留立即发布
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
- `frontend/src/features/publish/model/usePublishController.ts`
|
||||
|
||||
485
Docs/DevLogs/Day19.md
Normal file
485
Docs/DevLogs/Day19.md
Normal file
@@ -0,0 +1,485 @@
|
||||
## 🛡️ 发布中防误刷新(15:46,合并)
|
||||
|
||||
### 内容
|
||||
- 发布按钮文案统一为:`正在发布...请勿刷新或关闭网页`
|
||||
- 发布中启用浏览器 `beforeunload` 拦截,刷新/关闭页面会触发原生二次确认
|
||||
- 适用于发布管理页全部平台(抖音 / 微信视频号 / B站 / 小红书)
|
||||
- 后续优化已登记:发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)
|
||||
|
||||
### 涉及文件
|
||||
- `frontend/src/features/publish/model/usePublishController.ts`
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 发布成功截图稳定性优化(15:26,合并)
|
||||
|
||||
### 内容
|
||||
- 成功判定后先等待页面加载,再额外等待 `3s` 后截图,避免抓到半加载页面
|
||||
- 针对“截图里页面内容只占 1/3”问题,成功截图从 `full_page=True` 调整为视口截图 `full_page=False`
|
||||
- 视频号成功截图前额外恢复 `zoom=1.0`,避免流程缩放影响最终截图比例
|
||||
- 抖音成功截图同步应用相同策略,统一前端展示观感
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 视频号录屏 Debug 开关(15:12,已回收)
|
||||
|
||||
### 内容
|
||||
- 为视频号上传器新增 Playwright 录屏能力,开关受 `WEIXIN_DEBUG_ARTIFACTS && WEIXIN_RECORD_VIDEO` 控制
|
||||
- 新增视频号录屏配置项:
|
||||
- `WEIXIN_RECORD_VIDEO`
|
||||
- `WEIXIN_KEEP_SUCCESS_VIDEO`
|
||||
- `WEIXIN_RECORD_VIDEO_WIDTH`
|
||||
- `WEIXIN_RECORD_VIDEO_HEIGHT`
|
||||
- 上传流程在 `finally` 中统一保存录屏,失败必保留;成功录屏默认按开关清理
|
||||
- 排障阶段临时开启过视频号 debug/录屏;当前已回收为默认关闭(`run_backend.sh` 设为 `false`)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `run_backend.sh`
|
||||
- `Docs/DEPLOY_MANUAL.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔁 后端启动脚本统一为 run_backend.sh (15:00)
|
||||
|
||||
### 内容
|
||||
- 删除旧脚本 `run_backend_xvfb.sh`
|
||||
- 将 `run_backend.sh` 统一为 xvfb + headful 启动逻辑(不再保留非 xvfb 版本)
|
||||
- 默认端口从 `8010` 统一为 `8006`
|
||||
- 启动脚本默认关闭微信/抖音 debug 产物
|
||||
- 更新部署手册中的启动与 pm2 示例,统一使用 `run_backend.sh`
|
||||
|
||||
### 涉及文件
|
||||
- `run_backend.sh`
|
||||
- `run_backend_xvfb.sh` (deleted)
|
||||
- `Docs/DEPLOY_MANUAL.md`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 视频号卡顿与文案未写入修复 (14:52)
|
||||
|
||||
### 内容
|
||||
- 复盘日志确认视频号 `post_create` 请求已成功,但结果判定仅靠页面文案,导致长时间“等待发布结果”
|
||||
- 发布判定优化:`post_create` 成功且页面进入 `post/list` 时立即判定成功
|
||||
- 发布超时改为失败返回(不再 `success=true` 假成功)
|
||||
- “标题+标签写在视频描述”进一步加强:先按 `视频描述` 标签定位输入框,再做 placeholder 与 contenteditable 兜底
|
||||
- 视频号发布结果等待超时从 `180s` 收敛到 `90s`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🚦 视频号发布卡顿根因与快速判定 (14:45)
|
||||
|
||||
### 内容
|
||||
- 定位到卡顿根因是实际请求已提交(`post_create` 成功)但结果判定仍在轮询文本提示,导致长时间等待
|
||||
- 新增发布成功网络信号:监听 `post/post_create` 成功响应后标记已提交
|
||||
- 若已提交且页面已回到内容列表(`/post/list`),立即判定发布成功,不再等满超时
|
||||
- 新增发布接口失败信号:`post_create` 返回错误时立即失败返回
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 📸 视频号发布成功截图接入前端 (13:34)
|
||||
|
||||
### 内容
|
||||
- 为微信视频号新增“发布成功截图”能力:发布成功后直接对当前成功页截图
|
||||
- 截图存储沿用私有隔离目录:`private_outputs/publish_screenshots/{user_id}`
|
||||
- 返回前端的 `screenshot_url` 使用鉴权接口:`/api/publish/screenshot/{filename}`
|
||||
- 视频号上传器新增 `user_id` 透传,确保截图按用户隔离
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/services/publish_service.py`
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 视频号描述填充修正 + 关闭调试产物 (13:26)
|
||||
|
||||
### 内容
|
||||
- 按最新规则调整视频号文案填充:标题和标签统一写入“视频描述”输入区
|
||||
- 标签统一规范为 `#标签` 形式并去重
|
||||
- 若未找到“视频描述”输入区,直接返回失败,避免“发布成功但标题/标签为空”
|
||||
- 关闭视频号 debug 产物:新增 `WEIXIN_DEBUG_ARTIFACTS=false`,禁用调试日志与截图输出
|
||||
- `run_backend.sh` 增加 `WEIXIN_DEBUG_ARTIFACTS=false`,启动脚本层面强制关闭
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/weixin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `run_backend.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🚫 强制关闭抖音调试产物 (13:15)
|
||||
|
||||
### 内容
|
||||
- 进一步收紧为“默认不生成任何抖音 debug 截屏/日志/录屏”
|
||||
- 录屏开关改为依赖 `DOUYIN_DEBUG_ARTIFACTS && DOUYIN_RECORD_VIDEO`,避免单独误开
|
||||
- `run_backend.sh` 增加环境变量强制关闭:
|
||||
- `DOUYIN_DEBUG_ARTIFACTS=false`
|
||||
- `DOUYIN_RECORD_VIDEO=false`
|
||||
- 仅保留给用户看的发布成功截图(私有目录 + 鉴权访问)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `run_backend.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🧹 关闭调试截屏/录屏并清理历史文件 (13:08)
|
||||
|
||||
### 内容
|
||||
- 抖音调试产物默认关闭:
|
||||
- `DOUYIN_DEBUG_ARTIFACTS=false`
|
||||
- `DOUYIN_RECORD_VIDEO=false`
|
||||
- 保留功能信号监听(上传提交/封面生成/发布接口状态)用于流程判断,不依赖调试文件
|
||||
- 已删除现有抖音调试文件(`debug_screenshots` 下的 `douyin_*` 截图、日志与失败录屏)
|
||||
- 继续保留并展示“给用户看的发布成功截图”(用户隔离 + 鉴权访问)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/debug_screenshots/douyin_*` (deleted)
|
||||
- `backend/app/debug_screenshots/videos/douyin_*` (deleted)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 成功截图用户隔离 (12:58)
|
||||
|
||||
### 内容
|
||||
- 发布成功截图改为用户隔离存储,不再写入公开静态目录
|
||||
- 存储目录迁移到私有路径:`private_outputs/publish_screenshots/{user_id}`
|
||||
- 新增鉴权访问接口:`GET /api/publish/screenshot/{filename}`(必须登录,仅可访问本人截图)
|
||||
- 返回给前端的 `screenshot_url` 改为鉴权接口地址,避免跨用户直接猜路径访问
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/services/publish_service.py`
|
||||
- `backend/app/modules/publish/router.py`
|
||||
- `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 封面触发提速与审核中截图强化 (12:49)
|
||||
|
||||
### 内容
|
||||
- 修复“上传完成后长时间不进入封面”:当出现 `重新上传+预览` 且已收到视频提交信号时,立即进入封面步骤
|
||||
- 目标是减少“处理中”文案残留导致的额外等待
|
||||
- 成功截图逻辑强化为优先“真实点击审核中标签”,新增文本点击兜底,不再只用可见即通过
|
||||
- 若审核中列表未马上出现标题,自动刷新并再次进入审核中重查后再截图
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 登录态识别增强(避免误报上传失败) (12:41)
|
||||
|
||||
### 内容
|
||||
- 针对“未触发文件选择弹窗”误报,新增登录页识别:
|
||||
- URL 关键字:`passport/login/check_qrconnect/sso`
|
||||
- 页面文本:`扫码登录/验证码登录/立即登录/抖音APP扫码登录` 等
|
||||
- 登录控件:手机号/验证码输入框、登录按钮
|
||||
- 上传阶段重试后若识别为登录页,直接返回 `Cookie 已失效,请重新登录`
|
||||
- 避免把“实际掉登录”误判成“上传入口失效”
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 发布阶段超时与网络不佳快速失败 (12:30)
|
||||
|
||||
### 内容
|
||||
- 针对“网络不佳后长时间卡住”增加发布阶段快速失败
|
||||
- 上传完成后到发布结果设置总超时 `60s`(`POST_UPLOAD_STAGE_TIMEOUT`),超过直接失败
|
||||
- 识别发布接口 `create_v2` 的 HTTP 错误(如 403)并立即返回失败,不再等待 180 秒
|
||||
- 发布结果判定新增网络类失败文案匹配(`网络不佳/网络异常/请稍后重试`)
|
||||
- 阻塞弹窗关闭策略新增 `暂不设置`,避免“设置横封面获更多流量”弹窗阻塞点击发布
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧯 封面已完成但误判失败修复 (12:22)
|
||||
|
||||
### 内容
|
||||
- 针对报错“封面为必填但未设置成功”新增页面态兜底,避免封面已完成却未点击发布
|
||||
- 新增 `_is_cover_configured_on_page()`:通过 `横封面/竖封面` + 封面预览图判断页面已配置封面
|
||||
- 当出现 `horizontal_switch_missed` 或 `no_cover_button` 时,若页面已配置封面则允许继续发布
|
||||
- 封面必填主流程增加 `configured_fallback_continue` 兜底,降低误杀
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 成功截图切到审核中视图 (11:26)
|
||||
|
||||
### 内容
|
||||
- 按需求将“发布成功截图”改为内容管理 `审核中/待审核` 视图,不再截“全部作品”
|
||||
- 发布成功后先进入内容管理并点击 `审核中`(或 `待审核`)再截图
|
||||
- 截图前额外尝试等待当前标题出现在审核中列表,便于确认是最新发布作品
|
||||
- 发布超时兜底验证也改为优先在审核中列表查找标题
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 封面步骤按指定顺序强约束 (11:18)
|
||||
|
||||
### 内容
|
||||
- 按确认流程收紧旧发布页封面链路:
|
||||
- 作品描述填完 → 点击 `选择封面` → 点击 `设置横封面` → 点击 `完成` → 等待封面效果检测通过 → 才允许发布
|
||||
- 新增 `require_horizontal` 约束:封面必填场景必须切换到横封面,否则直接失败重试
|
||||
- 新增封面效果检测通过等待:优先 `cover/gen` 新请求信号,其次页面“检测通过”文案
|
||||
- 避免因漏点 `设置横封面` 导致后续卡住或误发布
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 横封面点击漏判修复 (11:10)
|
||||
|
||||
### 内容
|
||||
- 根据复现反馈修复“未点击设置横封面导致封面流程卡住”问题
|
||||
- 新增 `_switch_to_horizontal_cover()`,扩展横封面入口选择器(`设置横封面/横封面/横版封面`)
|
||||
- 进入封面弹窗后先关闭阻塞弹窗再点击横封面,点击失败会重试一次
|
||||
- 若页面存在横封面入口但始终未切换成功,直接返回失败并重试,避免长时间假等待
|
||||
- 新增日志:`[douyin][cover] switched_horizontal ...`、`horizontal_switch_missed`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 横封面后直接完成优化 (11:03)
|
||||
|
||||
### 内容
|
||||
- 根据实测反馈,在点击 `设置横封面` 后新增一次“立即点击完成”快速路径
|
||||
- 若平台已自动选中横封面,将直接确认并退出弹窗,不再执行后续封面扫描
|
||||
- 新增日志:`[douyin][cover] fast_confirm_after_switch ...`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 封面步骤提速优化 (10:58)
|
||||
|
||||
### 内容
|
||||
- 复盘日志确认旧发布页封面步骤存在明显耗时(示例:`required_by_text` 到 `cover selected` 约 35 秒)
|
||||
- 新增封面“快速确认”路径:若平台已默认选中封面,直接确认并跳过多余扫描
|
||||
- 收紧封面成功条件:仅“确认按钮点击成功”才算封面设置成功,避免误判
|
||||
- 缩短不必要等待并新增封面耗时日志:`[douyin][cover] fast_confirm/selected=... confirmed=... elapsed=...`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧾 发布成功截图前台展示 (10:48)
|
||||
|
||||
### 内容
|
||||
- 按需求删除 `run_backend_xvfb_live.sh`,不再提供实时直播脚本
|
||||
- 抖音发布成功时自动保存成功截图到 `outputs/publish_screenshots`
|
||||
- 发布接口返回 `screenshot_url`,前端发布结果卡片直接展示截图并支持点击查看大图
|
||||
- 发布结果不再 10 秒自动清空,方便用户确认“是否真正发布成功”
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `frontend/src/features/publish/model/usePublishController.ts`
|
||||
- `frontend/src/features/publish/ui/PublishPage.tsx`
|
||||
- `run_backend_xvfb_live.sh` (deleted)
|
||||
|
||||
---
|
||||
|
||||
## 🧬 抖音界面差异根因与环境对齐 (10:20)
|
||||
|
||||
### 内容
|
||||
- 定位到 Playwright 与手动 Win11 Chrome 的环境指纹不一致(Linux 平台 + 自动化上下文),可能触发不同灰度界面
|
||||
- 抖音上传器新增独立浏览器配置项,不再复用 `WEIXIN_*` 配置
|
||||
- 新增 `DOUYIN_*` 配置:`HEADLESS_MODE/USER_AGENT/LOCALE/TIMEZONE_ID/CHROME_PATH/BROWSER_CHANNEL/FORCE_SWIFTSHADER`
|
||||
- 上传器启动改为 `_build_launch_options()`,可直接切换到系统 Chrome + headful(推荐配合 xvfb)
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
## 🪄 新旧发布页封面逻辑分流 (10:28)
|
||||
|
||||
### 内容
|
||||
- 依据页面结构自动分流:
|
||||
- 新版发布页(封面非必填):默认跳过封面设置
|
||||
- 旧版发布页(出现 `设置封面` + `必填`):强制先设置封面
|
||||
- 新增 `_is_cover_required()` 判断,避免在新页面做多余封面操作
|
||||
- 若判定为非必填但点击发布失败,会回退尝试设置封面后再重试发布
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 📺 虚拟屏实时观看方案 (10:36)
|
||||
|
||||
### 内容
|
||||
- 新增 `run_backend_xvfb_live.sh`,在 Xvfb 下同时启动后端与实时画面转码
|
||||
- 通过 ffmpeg 抓取虚拟屏并输出 HLS:`/outputs/live/live.m3u8`
|
||||
- 适用于“边跑自动发布边实时观看”,不依赖 VNC
|
||||
- 默认仍保留失败录屏,HLS 用于过程实时观察
|
||||
|
||||
### 涉及文件
|
||||
- `run_backend_xvfb_live.sh`
|
||||
|
||||
---
|
||||
|
||||
## 🎥 抖音后台录屏能力 (09:55)
|
||||
|
||||
### 内容
|
||||
- 新增抖音自动发布过程录屏能力,便于定位“卡住在哪一步”
|
||||
- 录屏文件保存目录:`backend/app/debug_screenshots/videos`
|
||||
- 默认开启录屏,默认只保留失败录屏(成功录屏自动清理)
|
||||
- 每次执行会在网络日志追加录屏保存记录(`[douyin][record]`)
|
||||
- 增加发布阶段关键标记日志:`publish_wait ready`、`publish_click try/clicked`
|
||||
- 新增配置项:`DOUYIN_RECORD_VIDEO`、`DOUYIN_KEEP_SUCCESS_VIDEO`、`DOUYIN_RECORD_VIDEO_WIDTH`、`DOUYIN_RECORD_VIDEO_HEIGHT`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
- `backend/app/core/config.py`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 发布按钮等待逻辑修正 (10:00)
|
||||
|
||||
### 内容
|
||||
- 根据线上反馈,发布页不再做冗长前置等待,改为“尽快尝试点击发布”
|
||||
- 新增发布按钮定位策略(role + text 多选择器),避免 `exact role` 匹配失败导致假等待
|
||||
- 将发布按钮等待上限从上传超时(300s)独立为 `PUBLISH_BUTTON_TIMEOUT=60s`
|
||||
- 点击发布阶段统一走 `_click_publish_button`,并持续记录 `publish_wait/publish_click` 日志
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 上传完成特征判定增强 (10:07)
|
||||
|
||||
### 内容
|
||||
- 基于实测页面特征补齐“上传中/上传完成”判定:
|
||||
- 上传中:`上传过程中请不要刷新`、`取消上传`、`已上传/当前速度/剩余时间`
|
||||
- 上传完成:`重新上传` + `预览视频/预览封面/标题`
|
||||
- 仅在确认上传完成后才允许执行发布点击,避免“未传完提前发布”
|
||||
- 新增上传等待日志:`[douyin][upload_wait] ...`,可直观看到卡在上传中还是等完成信号
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⏸️ 上传完成后延时发布 (10:10)
|
||||
|
||||
### 内容
|
||||
- 根据实测反馈,增加“上传完成后固定等待 2 秒”再点发布
|
||||
- 避免刚出现完成信号就立即点击,给前端状态收敛留缓冲
|
||||
- 新增日志标记:`[douyin][upload_ready] wait_before_publish=2s`
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 恢复封面设置流程 (10:14)
|
||||
|
||||
### 内容
|
||||
- 按实测需求恢复“上传完成后先设置封面,再发布”流程
|
||||
- 封面设置改为最多尝试 2 次,成功写入 `[douyin][cover] selected`
|
||||
- 若封面未设置成功则直接终止发布并保存截图 `cover_not_selected`
|
||||
- 避免出现“未设封面就点击发布”的情况
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 抖音发布流程修复 (09:20)
|
||||
|
||||
### 内容
|
||||
- 按最新页面流程改为先进入首页并点击 `高清发布`,再进入上传页
|
||||
- 新增未发布草稿处理:检测到 `你还有上次未发布的视频` 时自动点击 `放弃`
|
||||
- 上传策略改为优先点击 `上传视频` 并走 file chooser,失败后再回退多 input 选择器
|
||||
- 只有检测到 `基础信息/作品描述/发布设置/重新上传` 等发布态信号才继续,避免误判“已上传”
|
||||
- 修复无扩展名视频临时文件策略:优先 hardlink,失败时 copy,移除 symlink 回退
|
||||
- 适配当前智能封面流程:跳过手动封面操作
|
||||
- 话题填写改为在简介/描述区域使用 `#标签` 形式追加
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 抖音等待链路再收敛 (09:52)
|
||||
|
||||
### 内容
|
||||
- 根据“选完视频即进入发布页”流程,移除独立的上传完成轮询阶段
|
||||
- 改为在点击发布前统一等待“发布按钮可点击”,避免重复等待导致总时长偏长
|
||||
- 新增 `publish_wait` 调试日志,按秒记录按钮可点击等待时长
|
||||
- 超时文案改为明确提示“发布按钮长时间不可点击”
|
||||
- 上传入口改为严格 file chooser 流程:只走“点击上传视频 → 选择文件 → 进入发布页”链路
|
||||
- 移除直接 input 回退上传,避免绕开上传入口导致状态机异常
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 抖音卡慢环节定位与修复 (09:45)
|
||||
|
||||
### 内容
|
||||
- 通过 `douyin_network.log` 定位到卡慢发生在“上传完成判定”阶段,而非真正提交发布接口
|
||||
- 新增上传完成网络信号:`CommitUploadInner` 成功与封面生成成功信号写入日志
|
||||
- 收紧“上传完成”判定,移除 `publish_button_enabled` 这种过早放行条件
|
||||
- 仅在检测到 `重新上传/重新选择` 或上传提交信号后才进入下一步,降低误判导致的长等待
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ✅ 抖音发布结果判定修正 (09:38)
|
||||
|
||||
### 内容
|
||||
- 修复“发布检测超时仍返回 success=true”的问题,超时场景改为 `success=false`
|
||||
- 优化超时返回文案,明确为“发布状态未知,需要后台确认”
|
||||
- 下线过于宽松的管理页兜底判定(仅出现 `审核中` 不再当作发布成功)
|
||||
- 超时时即使管理页出现同名标题也不直接判定成功,避免旧作品同名导致误报
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 抖音上传完成判定优化 (09:34)
|
||||
|
||||
### 内容
|
||||
- 根据最新日志确认文件上传已开始并有分片上传请求成功,但流程长时间停留在“等待上传完成”
|
||||
- 扩展“上传完成”判定条件,不再只依赖单一 `long-card + 重新上传` 选择器
|
||||
- 新增上传完成信号:`重新上传/重新选择` 可见、发布按钮可用、`发布设置` 或 `预览视频` 可见
|
||||
- 上传等待日志增加耗时秒数,便于判断是否真实卡住
|
||||
|
||||
### 涉及文件
|
||||
- `backend/app/services/uploader/douyin_uploader.py`
|
||||
@@ -264,6 +264,13 @@ import { formatDate } from '@/shared/lib/media';
|
||||
|
||||
---
|
||||
|
||||
## 发布页交互规则
|
||||
|
||||
- 发布按钮在未选择任何平台时禁用
|
||||
- 仅保留“立即发布”,不再提供定时发布 UI/参数
|
||||
|
||||
---
|
||||
|
||||
## 新增页面 Checklist
|
||||
|
||||
1. [ ] 导入 `import api from '@/shared/api/axios'`
|
||||
|
||||
@@ -27,7 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **发布配置**: 设置视频标题、标签、简介。
|
||||
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
|
||||
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
|
||||
- **定时任务**: 支持 "立即发布" 或 "定时发布"。
|
||||
- **发布方式**: 仅支持 "立即发布"。
|
||||
|
||||
### 3. 声音克隆 [Day 13 新增]
|
||||
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ViGent2 开发任务清单 (Task Log)
|
||||
|
||||
**项目**: ViGent2 数字人口播视频生成系统
|
||||
**进度**: 100% (Day 18 - 后端模块化与规范完善)
|
||||
**更新时间**: 2026-02-05
|
||||
**进度**: 100% (Day 19 - 自动发布稳定性与发布体验优化)
|
||||
**更新时间**: 2026-02-06
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,17 @@
|
||||
|
||||
> 这里记录了每一天的核心开发内容与 milestone。
|
||||
|
||||
### Day 18: 后端模块化与规范完善 (Current) 🚀
|
||||
### Day 19: 自动发布稳定性与发布体验优化 (Current) 🚀
|
||||
- [x] **抖音发布稳定性**: 上传入口、封面流程、发布重试、登录失效识别与网络失败快速返回全面增强。
|
||||
- [x] **视频号发布修复**: 标题+标签统一写入“视频描述”,`post_create` 成功信号快速判定,超时改为失败返回。
|
||||
- [x] **成功截图闭环**: 抖音/视频号发布成功截图接入前端,支持用户隔离存储与鉴权访问。
|
||||
- [x] **截图观感优化**: 成功截图延后 3 秒并改为视口截图,修复“截图内容仅占 1/3”问题。
|
||||
- [x] **调试能力开关化**: 新增视频号录屏配置,默认可按环境变量开关,失败排障更直观。
|
||||
- [x] **启动链路统一**: 合并为 `run_backend.sh`(xvfb + headful),统一端口 `8006`,减少多进程混淆。
|
||||
- [x] **发布页防误操作**: 发布中按钮提示“请勿刷新或关闭网页”,并启用刷新/关页二次确认拦截。
|
||||
- [ ] **后续优化**: 发布任务状态恢复机制(任务化 + 状态持久化 + 前端轮询恢复)。
|
||||
|
||||
### Day 18: 后端模块化与规范完善
|
||||
- [x] **模块化迁移**: 路由透传 `modules/*`,业务逻辑集中到 service/workflow。
|
||||
- [x] **视频生成拆分**: 生成流程下沉 workflow,任务状态统一 TaskStore。
|
||||
- [x] **Redis 任务存储**: Redis 优先,不可用自动回退内存。
|
||||
@@ -92,9 +102,10 @@
|
||||
|
||||
## 🛤️ 后续规划 (Roadmap)
|
||||
|
||||
### 🔴 优先待办
|
||||
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
|
||||
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
|
||||
### 🔴 优先待办
|
||||
- [ ] **批量生成架构**: 支持 Excel 导入,批量生产视频。
|
||||
- [ ] **定时任务后台化**: 迁移前端触发的定时发布到后端 APScheduler。
|
||||
- [ ] **发布任务恢复机制**: 发布任务化 + 状态持久化 + 前端断点恢复,解决刷新后状态丢失。
|
||||
|
||||
### 🔵 长期探索
|
||||
- [ ] **容器化交付**: 提供完整的 Docker Compose 一键部署包。
|
||||
@@ -110,7 +121,7 @@
|
||||
| **Web UI** | 100% | ✅ 稳定 (移动端适配) |
|
||||
| **唇形同步** | 100% | ✅ LatentSync 1.6 |
|
||||
| **TTS 配音** | 100% | ✅ EdgeTTS + Qwen3 |
|
||||
| **自动发布** | 100% | ✅ B站/抖音/小红书 |
|
||||
| **自动发布** | 100% | ✅ 抖音/微信视频号/B站/小红书 |
|
||||
| **用户认证** | 100% | ✅ 手机号 + JWT |
|
||||
| **部署运维** | 100% | ✅ PM2 + Watchdog |
|
||||
|
||||
@@ -118,5 +129,5 @@
|
||||
|
||||
## 📎 相关文档
|
||||
|
||||
- [详细开发日志 (DevLogs)](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DevLogs/)
|
||||
- [部署手册 (DEPLOY_MANUAL)](file:///d:/CodingProjects/Antigravity/ViGent2/Docs/DEPLOY_MANUAL.md)
|
||||
- [详细开发日志 (DevLogs)](Docs/DevLogs/)
|
||||
- [部署手册 (DEPLOY_MANUAL)](Docs/DEPLOY_MANUAL.md)
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
|
||||
|
||||
### 平台化功能
|
||||
- 📱 **全自动发布** - 支持抖音/B站/小红书定时发布,微信视频号预留配置;扫码登录 + Cookie 持久化。
|
||||
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
|
||||
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
|
||||
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
|
||||
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
|
||||
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
|
||||
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
|
||||
|
||||
---
|
||||
|
||||
@@ -7,10 +7,42 @@ class Settings(BaseSettings):
|
||||
UPLOAD_DIR: Path = BASE_DIR.parent / "uploads"
|
||||
OUTPUT_DIR: Path = BASE_DIR.parent / "outputs"
|
||||
ASSETS_DIR: Path = BASE_DIR.parent / "assets"
|
||||
PUBLISH_SCREENSHOT_DIR: Path = BASE_DIR.parent / "private_outputs" / "publish_screenshots"
|
||||
|
||||
# 数据库/缓存
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
DEBUG: bool = True
|
||||
# 数据库/缓存
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Playwright 配置
|
||||
WEIXIN_HEADLESS_MODE: str = "headless-new"
|
||||
WEIXIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
WEIXIN_LOCALE: str = "zh-CN"
|
||||
WEIXIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
WEIXIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
WEIXIN_BROWSER_CHANNEL: str = ""
|
||||
WEIXIN_FORCE_SWIFTSHADER: bool = True
|
||||
WEIXIN_TRANSCODE_MODE: str = "reencode"
|
||||
WEIXIN_DEBUG_ARTIFACTS: bool = False
|
||||
WEIXIN_RECORD_VIDEO: bool = False
|
||||
WEIXIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
WEIXIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
WEIXIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
|
||||
# Douyin Playwright 配置
|
||||
DOUYIN_HEADLESS_MODE: str = "headless-new"
|
||||
DOUYIN_USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
DOUYIN_LOCALE: str = "zh-CN"
|
||||
DOUYIN_TIMEZONE_ID: str = "Asia/Shanghai"
|
||||
DOUYIN_CHROME_PATH: str = "/usr/bin/google-chrome"
|
||||
DOUYIN_BROWSER_CHANNEL: str = ""
|
||||
DOUYIN_FORCE_SWIFTSHADER: bool = True
|
||||
|
||||
# Douyin 调试录屏
|
||||
DOUYIN_DEBUG_ARTIFACTS: bool = False
|
||||
DOUYIN_RECORD_VIDEO: bool = False
|
||||
DOUYIN_KEEP_SUCCESS_VIDEO: bool = False
|
||||
DOUYIN_RECORD_VIDEO_WIDTH: int = 1280
|
||||
DOUYIN_RECORD_VIDEO_HEIGHT: int = 720
|
||||
|
||||
# TTS 配置
|
||||
DEFAULT_TTS_VOICE: str = "zh-CN-YunxiNeural"
|
||||
|
||||
@@ -15,17 +15,19 @@ async def login_helper_page(platform: str, request: Request):
|
||||
登录后JavaScript自动提取Cookie并POST回服务器
|
||||
"""
|
||||
|
||||
platform_urls = {
|
||||
"bilibili": "https://www.bilibili.com/",
|
||||
"douyin": "https://creator.douyin.com/",
|
||||
"xiaohongshu": "https://creator.xiaohongshu.com/"
|
||||
}
|
||||
platform_urls = {
|
||||
"bilibili": "https://www.bilibili.com/",
|
||||
"douyin": "https://creator.douyin.com/",
|
||||
"xiaohongshu": "https://creator.xiaohongshu.com/",
|
||||
"weixin": "https://channels.weixin.qq.com/"
|
||||
}
|
||||
|
||||
platform_names = {
|
||||
"bilibili": "B站",
|
||||
"douyin": "抖音",
|
||||
"xiaohongshu": "小红书"
|
||||
}
|
||||
platform_names = {
|
||||
"bilibili": "B站",
|
||||
"douyin": "抖音",
|
||||
"xiaohongshu": "小红书",
|
||||
"weixin": "微信视频号"
|
||||
}
|
||||
|
||||
if platform not in platform_urls:
|
||||
return "<h1>不支持的平台</h1>"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""
|
||||
发布管理 API (支持用户认证)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import re
|
||||
from loguru import logger
|
||||
from app.services.publish_service import PublishService
|
||||
from app.core.response import success_response
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
publish_service = PublishService()
|
||||
@@ -29,7 +33,7 @@ class PublishResponse(BaseModel):
|
||||
url: Optional[str] = None
|
||||
|
||||
# Supported platforms for validation
|
||||
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
|
||||
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu", "weixin"}
|
||||
|
||||
|
||||
def _get_user_id(request: Request) -> Optional[str]:
|
||||
@@ -118,7 +122,7 @@ async def get_login_status(platform: str, req: Request):
|
||||
message = result.get("message", "")
|
||||
return success_response(result, message=message)
|
||||
|
||||
@router.post("/cookies/save/{platform}")
|
||||
@router.post("/cookies/save/{platform}")
|
||||
async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
|
||||
"""
|
||||
保存从客户端浏览器提取的Cookie
|
||||
@@ -139,3 +143,23 @@ async def save_platform_cookie(platform: str, cookie_data: dict, req: Request):
|
||||
|
||||
message = result.get("message", "")
|
||||
return success_response(result, message=message)
|
||||
|
||||
|
||||
@router.get("/screenshot/{filename}")
|
||||
async def get_publish_screenshot(
|
||||
filename: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
if not re.match(r"^[A-Za-z0-9_.-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="非法文件名")
|
||||
|
||||
user_id = str(current_user.get("id") or "")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
user_dir = re.sub(r"[^A-Za-z0-9_-]", "_", user_id)[:64] or "legacy"
|
||||
file_path = settings.PUBLISH_SCREENSHOT_DIR / user_dir / filename
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="截图不存在")
|
||||
|
||||
return FileResponse(path=str(file_path), media_type="image/png")
|
||||
|
||||
@@ -17,7 +17,8 @@ from app.services.storage import storage_service
|
||||
# Import platform uploaders
|
||||
from .uploader.bilibili_uploader import BilibiliUploader
|
||||
from .uploader.douyin_uploader import DouyinUploader
|
||||
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .uploader.xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .uploader.weixin_uploader import WeixinUploader
|
||||
|
||||
|
||||
class PublishService:
|
||||
@@ -26,7 +27,7 @@ class PublishService:
|
||||
# 支持的平台配置
|
||||
PLATFORMS: Dict[str, Dict[str, Any]] = {
|
||||
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
|
||||
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": True},
|
||||
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
|
||||
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
|
||||
}
|
||||
@@ -174,25 +175,36 @@ class PublishService:
|
||||
tid=kwargs.get('tid', 122),
|
||||
copyright=kwargs.get('copyright', 1)
|
||||
)
|
||||
elif platform == "douyin":
|
||||
uploader = DouyinUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description
|
||||
)
|
||||
else:
|
||||
elif platform == "douyin":
|
||||
uploader = DouyinUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
elif platform == "xiaohongshu":
|
||||
uploader = XiaohongshuUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description
|
||||
)
|
||||
elif platform == "weixin":
|
||||
uploader = WeixinUploader(
|
||||
title=title,
|
||||
file_path=local_video_path,
|
||||
tags=tags,
|
||||
publish_date=publish_time,
|
||||
account_file=str(account_file),
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[发布] {platform} 上传功能尚未实现")
|
||||
return {
|
||||
"success": False,
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
QR码自动登录服务
|
||||
后端Playwright无头模式获取二维码,前端扫码后自动保存Cookie
|
||||
"""
|
||||
import asyncio
|
||||
import asyncio
|
||||
import time
|
||||
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
|
||||
from typing import Optional, Dict, Any, List, Sequence, Mapping, Union
|
||||
from playwright.async_api import async_playwright, Page, Frame, BrowserContext, Browser, Playwright as PW
|
||||
from loguru import logger
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class QRLoginService:
|
||||
class QRLoginService:
|
||||
"""QR码登录服务"""
|
||||
|
||||
# 登录监控超时 (秒)
|
||||
LOGIN_TIMEOUT = 120
|
||||
|
||||
def __init__(self, platform: str, cookies_dir: Path) -> None:
|
||||
self.platform = platform
|
||||
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
|
||||
@@ -30,7 +32,7 @@ class QRLoginService:
|
||||
self.context: Optional[BrowserContext] = None
|
||||
|
||||
# 每个平台使用多个选择器 (使用逗号分隔,Playwright会同时等待它们)
|
||||
self.platform_configs = {
|
||||
self.platform_configs = {
|
||||
"bilibili": {
|
||||
"url": "https://passport.bilibili.com/login",
|
||||
"qr_selectors": [
|
||||
@@ -52,17 +54,110 @@ class QRLoginService:
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"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"
|
||||
},
|
||||
"weixin": {
|
||||
"url": "https://channels.weixin.qq.com/platform/",
|
||||
"qr_selectors": [
|
||||
"div[class*='qrcode'] img",
|
||||
"img[alt*='二维码']",
|
||||
"img[src*='qr']",
|
||||
"canvas",
|
||||
"svg",
|
||||
"img[class*='qr']"
|
||||
],
|
||||
"success_indicator": "https://channels.weixin.qq.com/platform"
|
||||
}
|
||||
}
|
||||
|
||||
def _resolve_headless_mode(self) -> str:
|
||||
if self.platform != "weixin":
|
||||
return "headless"
|
||||
mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
|
||||
return mode or "headful"
|
||||
|
||||
def _is_square_bbox(self, bbox: Optional[Dict[str, float]], min_side: int = 100) -> bool:
|
||||
if not bbox:
|
||||
return False
|
||||
width = bbox.get("width", 0)
|
||||
height = bbox.get("height", 0)
|
||||
if width < min_side or height < min_side:
|
||||
return False
|
||||
if height == 0:
|
||||
return False
|
||||
ratio = width / height
|
||||
return 0.75 <= ratio <= 1.33
|
||||
|
||||
async def _pick_best_candidate(self, locator, min_side: int = 100):
|
||||
best = None
|
||||
best_area = 0
|
||||
try:
|
||||
count = await locator.count()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
candidate = locator.nth(i)
|
||||
if not await candidate.is_visible():
|
||||
continue
|
||||
bbox = await candidate.bounding_box()
|
||||
if not self._is_square_bbox(bbox, min_side=min_side):
|
||||
continue
|
||||
area = bbox["width"] * bbox["height"]
|
||||
if area > best_area:
|
||||
best = candidate
|
||||
best_area = area
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return best
|
||||
|
||||
async def _find_qr_in_frames(self, page: Page, selectors: List[str], min_side: int):
|
||||
combined_selector = ", ".join(selectors)
|
||||
for frame in page.frames:
|
||||
if frame == page.main_frame:
|
||||
continue
|
||||
try:
|
||||
locator = frame.locator(combined_selector)
|
||||
candidate = await self._pick_best_candidate(locator, min_side=min_side)
|
||||
if candidate:
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def _scan_qr_candidates(self, page: Page, selectors: List[str], min_side: int):
|
||||
combined_selector = ", ".join(selectors)
|
||||
try:
|
||||
locator = page.locator(combined_selector)
|
||||
candidate = await self._pick_best_candidate(locator, min_side=min_side)
|
||||
if candidate:
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await self._find_qr_in_frames(page, selectors, min_side=min_side)
|
||||
|
||||
async def _try_text_strategy_in_frames(self, page: Page):
|
||||
for frame in page.frames:
|
||||
if frame == page.main_frame:
|
||||
continue
|
||||
try:
|
||||
candidate = await self._try_text_strategy(frame)
|
||||
if candidate:
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
async def start_login(self) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -77,26 +172,45 @@ class QRLoginService:
|
||||
config = self.platform_configs[self.platform]
|
||||
|
||||
try:
|
||||
# 1. 启动 Playwright (不使用 async with,手动管理生命周期)
|
||||
self.playwright = await async_playwright().start()
|
||||
# 1. 启动 Playwright (不使用 async with,手动管理生命周期)
|
||||
self.playwright = await async_playwright().start()
|
||||
|
||||
mode = self._resolve_headless_mode()
|
||||
headless = mode not in ("headful", "false", "0", "no")
|
||||
launch_args = [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage'
|
||||
]
|
||||
if headless and mode in ("new", "headless-new", "headless_new"):
|
||||
launch_args.append("--headless=new")
|
||||
|
||||
# Stealth模式启动浏览器
|
||||
launch_options: Dict[str, Any] = {
|
||||
"headless": headless,
|
||||
"args": launch_args,
|
||||
}
|
||||
if self.platform == "weixin":
|
||||
chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
|
||||
if chrome_path:
|
||||
if Path(chrome_path).exists():
|
||||
launch_options["executable_path"] = chrome_path
|
||||
else:
|
||||
logger.warning(f"[weixin] WEIXIN_CHROME_PATH not found: {chrome_path}")
|
||||
else:
|
||||
channel = (settings.WEIXIN_BROWSER_CHANNEL or "").strip()
|
||||
if channel:
|
||||
launch_options["channel"] = channel
|
||||
|
||||
self.browser = await self.playwright.chromium.launch(**launch_options)
|
||||
|
||||
# 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'
|
||||
)
|
||||
# 配置真实浏览器特征
|
||||
self.context = await self.browser.new_context(
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
user_agent=settings.WEIXIN_USER_AGENT,
|
||||
locale=settings.WEIXIN_LOCALE,
|
||||
timezone_id=settings.WEIXIN_TIMEZONE_ID
|
||||
)
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
@@ -106,14 +220,26 @@ class QRLoginService:
|
||||
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"])
|
||||
urls_to_try = [config["url"]]
|
||||
if self.platform == "weixin":
|
||||
urls_to_try = [
|
||||
"https://channels.weixin.qq.com/platform/",
|
||||
"https://channels.weixin.qq.com/",
|
||||
]
|
||||
|
||||
qr_image = None
|
||||
for url in urls_to_try:
|
||||
logger.info(f"[{self.platform}] 打开登录页: {url}")
|
||||
wait_until = "domcontentloaded" if self.platform == "weixin" else "networkidle"
|
||||
await page.goto(url, wait_until=wait_until)
|
||||
|
||||
# 等待页面加载
|
||||
await asyncio.sleep(1 if self.platform == "weixin" else 2)
|
||||
|
||||
# 提取二维码 (并行策略)
|
||||
qr_image = await self._extract_qr_code(page, config["qr_selectors"])
|
||||
if qr_image:
|
||||
break
|
||||
|
||||
if not qr_image:
|
||||
await self._cleanup()
|
||||
@@ -180,21 +306,35 @@ class QRLoginService:
|
||||
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}")
|
||||
# 其他平台 (小红书/微信等):保持原顺序 CSS -> Text
|
||||
# 策略1: CSS 选择器
|
||||
try:
|
||||
combined_selector = ", ".join(selectors)
|
||||
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
|
||||
if self.platform == "weixin":
|
||||
min_side = 120
|
||||
start_time = time.monotonic()
|
||||
while time.monotonic() - start_time < 12:
|
||||
qr_element = await self._scan_qr_candidates(page, selectors, min_side=min_side)
|
||||
if qr_element:
|
||||
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
||||
locator = page.locator(combined_selector)
|
||||
qr_element = await self._pick_best_candidate(locator, min_side=100)
|
||||
if qr_element:
|
||||
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
|
||||
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 not qr_element:
|
||||
qr_element = await self._try_text_strategy(page)
|
||||
|
||||
if not qr_element and self.platform == "weixin":
|
||||
qr_element = await self._try_text_strategy_in_frames(page)
|
||||
|
||||
# 如果找到元素,截图返回
|
||||
if qr_element:
|
||||
@@ -214,11 +354,20 @@ class QRLoginService:
|
||||
|
||||
return None
|
||||
|
||||
async def _try_text_strategy(self, page: Page) -> Optional[Any]:
|
||||
async def _try_text_strategy(self, page: Union[Page, Frame]) -> Optional[Any]:
|
||||
"""基于文本查找二维码图片"""
|
||||
try:
|
||||
logger.debug(f"[{self.platform}] 策略Text: 开始搜索...")
|
||||
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP", "使用APP扫码"]
|
||||
keywords = [
|
||||
"扫码登录",
|
||||
"二维码",
|
||||
"打开抖音",
|
||||
"抖音APP",
|
||||
"使用APP扫码",
|
||||
"微信扫码",
|
||||
"请使用微信扫码",
|
||||
"视频号"
|
||||
]
|
||||
|
||||
for kw in keywords:
|
||||
try:
|
||||
@@ -229,15 +378,12 @@ class QRLoginService:
|
||||
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
|
||||
candidates = parent.locator("img, canvas")
|
||||
min_side = 120 if self.platform == "weixin" else 100
|
||||
best = await self._pick_best_candidate(candidates, min_side=min_side)
|
||||
if best:
|
||||
logger.info(f"[{self.platform}] 策略Text: 成功")
|
||||
return best
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -248,8 +394,23 @@ class QRLoginService:
|
||||
"""监控登录状态"""
|
||||
try:
|
||||
logger.info(f"[{self.platform}] 开始监控登录状态...")
|
||||
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
|
||||
target_cookie = key_cookies.get(self.platform, "")
|
||||
key_cookies = {
|
||||
"bilibili": ["SESSDATA"],
|
||||
"douyin": ["sessionid"],
|
||||
"xiaohongshu": ["web_session"],
|
||||
"weixin": [
|
||||
"wxuin",
|
||||
"wxsid",
|
||||
"pass_ticket",
|
||||
"webwx_data_ticket",
|
||||
"uin",
|
||||
"skey",
|
||||
"p_uin",
|
||||
"p_skey",
|
||||
"pac_uid",
|
||||
],
|
||||
}
|
||||
target_cookies = key_cookies.get(self.platform, [])
|
||||
|
||||
for i in range(self.LOGIN_TIMEOUT):
|
||||
await asyncio.sleep(1)
|
||||
@@ -257,9 +418,9 @@ class QRLoginService:
|
||||
try:
|
||||
if not self.context: break # 避免意外关闭
|
||||
|
||||
cookies = await self.context.cookies()
|
||||
cookies = [dict(cookie) for cookie in await self.context.cookies()]
|
||||
current_url = page.url
|
||||
has_cookie = any(c['name'] == target_cookie for c in cookies)
|
||||
has_cookie = any((c.get('name') in target_cookies) for c in cookies) if target_cookies else False
|
||||
|
||||
if i % 5 == 0:
|
||||
logger.debug(f"[{self.platform}] 等待登录... HasCookie: {has_cookie}")
|
||||
@@ -270,7 +431,7 @@ class QRLoginService:
|
||||
await asyncio.sleep(2) # 缓冲
|
||||
|
||||
# 保存Cookie
|
||||
final_cookies = await self.context.cookies()
|
||||
final_cookies = [dict(cookie) for cookie in await self.context.cookies()]
|
||||
await self._save_cookies(final_cookies)
|
||||
break
|
||||
|
||||
@@ -307,14 +468,14 @@ class QRLoginService:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
async def _save_cookies(self, cookies: List[Dict[str, Any]]) -> None:
|
||||
async def _save_cookies(self, cookies: Sequence[Mapping[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}
|
||||
cookie_dict = {c.get('name'): c.get('value') for c in cookies if c.get('name')}
|
||||
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
from .xiaohongshu_uploader import XiaohongshuUploader
|
||||
from .weixin_uploader import WeixinUploader
|
||||
|
||||
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader']
|
||||
__all__ = ['BaseUploader', 'BilibiliUploader', 'DouyinUploader', 'XiaohongshuUploader', 'WeixinUploader']
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1426
backend/app/services/uploader/weixin_uploader.py
Normal file
1426
backend/app/services/uploader/weixin_uploader.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,14 @@ interface Video {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
platform: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
url?: string | null;
|
||||
screenshot_url?: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
api.get<ApiResponse<any>>(url).then((res) => unwrap(res.data));
|
||||
|
||||
@@ -45,9 +53,7 @@ export const usePublishController = () => {
|
||||
const [title, setTitle] = useState<string>("");
|
||||
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 [publishResults, setPublishResults] = useState<PublishResult[]>([]);
|
||||
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null);
|
||||
const [qrPlatform, setQrPlatform] = useState<string | null>(null);
|
||||
const [isLoadingQR, setIsLoadingQR] = useState(false);
|
||||
@@ -231,19 +237,21 @@ export const usePublishController = () => {
|
||||
title,
|
||||
tags: tagList,
|
||||
description: "",
|
||||
publish_time: scheduleMode === "scheduled" && publishTime
|
||||
? new Date(publishTime).toISOString()
|
||||
: null,
|
||||
});
|
||||
|
||||
const result = unwrap(res);
|
||||
setPublishResults((prev) => [...prev, result]);
|
||||
// 发布成功后10秒自动清除结果
|
||||
if (result.success) {
|
||||
setTimeout(() => {
|
||||
setPublishResults((prev) => prev.filter((r) => r !== result));
|
||||
}, 10000);
|
||||
}
|
||||
const screenshotUrl =
|
||||
typeof result.screenshot_url === "string"
|
||||
? resolveMediaUrl(result.screenshot_url) || result.screenshot_url
|
||||
: undefined;
|
||||
const nextResult: PublishResult = {
|
||||
platform: result.platform || platform,
|
||||
success: Boolean(result.success),
|
||||
message: result.message || "",
|
||||
url: result.url,
|
||||
screenshot_url: screenshotUrl,
|
||||
};
|
||||
setPublishResults((prev) => [...prev, nextResult]);
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || String(error);
|
||||
setPublishResults((prev) => [
|
||||
@@ -256,6 +264,20 @@ export const usePublishController = () => {
|
||||
setIsPublishing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPublishing) return;
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "发布进行中,请勿刷新页面";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [isPublishing]);
|
||||
|
||||
// SWR Polling for Login Status
|
||||
useSWR(
|
||||
qrPlatform ? `${apiBase}/api/publish/login/status/${qrPlatform}` : null,
|
||||
@@ -372,10 +394,6 @@ export const usePublishController = () => {
|
||||
setTags,
|
||||
isPublishing,
|
||||
publishResults,
|
||||
scheduleMode,
|
||||
setScheduleMode,
|
||||
publishTime,
|
||||
setPublishTime,
|
||||
qrCodeImage,
|
||||
qrPlatform,
|
||||
isLoadingQR,
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
RotateCcw,
|
||||
LogOut,
|
||||
QrCode,
|
||||
Rocket,
|
||||
Clock,
|
||||
Search,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
@@ -33,10 +31,6 @@ export function PublishPage() {
|
||||
setTags,
|
||||
isPublishing,
|
||||
publishResults,
|
||||
scheduleMode,
|
||||
setScheduleMode,
|
||||
publishTime,
|
||||
setPublishTime,
|
||||
qrCodeImage,
|
||||
qrPlatform,
|
||||
isLoadingQR,
|
||||
@@ -352,58 +346,13 @@ export function PublishPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 定时发布 */}
|
||||
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
⏰ 发布设置
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setScheduleMode("now")}
|
||||
className={`flex-1 p-3 rounded-xl border-2 transition-all ${scheduleMode === "now"
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<Rocket className="h-5 w-5 mx-auto mb-1" />
|
||||
<span className="text-white text-sm">立即发布</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScheduleMode("scheduled")}
|
||||
className={`flex-1 p-3 rounded-xl border-2 transition-all ${scheduleMode === "scheduled"
|
||||
? "border-purple-500 bg-purple-500/20"
|
||||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-5 w-5 mx-auto mb-1" />
|
||||
<span className="text-white text-sm">定时发布</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scheduleMode === "scheduled" && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={publishTime}
|
||||
onChange={(e) => setPublishTime(e.target.value)}
|
||||
className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 发布按钮 */}
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || (scheduleMode === "scheduled" && !publishTime)}
|
||||
disabled={isPublishing || selectedPlatforms.length === 0}
|
||||
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold text-lg hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPublishing
|
||||
? "正在发布..."
|
||||
: scheduleMode === "scheduled"
|
||||
? "定时发布"
|
||||
: "立即发布"}
|
||||
{isPublishing ? "正在发布...请勿刷新或关闭网页" : "立即发布"}
|
||||
</button>
|
||||
|
||||
{/* 发布结果 */}
|
||||
@@ -432,6 +381,23 @@ export function PublishPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{result.message}</p>
|
||||
{result.success && result.screenshot_url && (
|
||||
<div className="mt-3 rounded-lg border border-white/10 bg-black/20 p-2">
|
||||
<p className="text-xs text-gray-400 mb-2">发布成功截图</p>
|
||||
<a
|
||||
href={result.screenshot_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<img
|
||||
src={result.screenshot_url}
|
||||
alt="发布成功截图"
|
||||
className="w-full rounded-md border border-white/10"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
#!/bin/bash
|
||||
# 启动 ViGent2 后端 (FastAPI)
|
||||
cd "$(dirname "$0")/backend"
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8006
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export WEIXIN_HEADLESS_MODE=headful
|
||||
export WEIXIN_DEBUG_ARTIFACTS=false
|
||||
export WEIXIN_RECORD_VIDEO=false
|
||||
export DOUYIN_DEBUG_ARTIFACTS=false
|
||||
export DOUYIN_RECORD_VIDEO=false
|
||||
PORT=${PORT:-8006}
|
||||
|
||||
cd "$BASE_DIR/backend"
|
||||
exec xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./venv/bin/uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
|
||||
|
||||
Reference in New Issue
Block a user