This commit is contained in:
Kevin Wong
2026-02-06 16:02:58 +08:00
parent be6a3436bb
commit 945262a7fc
20 changed files with 3709 additions and 482 deletions

View File

@@ -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/

View File

@@ -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`: 字幕样式列表

View File

@@ -28,6 +28,12 @@ node --version
# 检查 FFmpeg
ffmpeg -version
# 检查 Chrome (视频号发布)
google-chrome --version
# 检查 Xvfb
xvfb-run --help
# 检查 pm2 (用于服务管理)
pm2 --version
@@ -40,8 +46,17 @@ redis-server --version
sudo apt update
sudo apt install ffmpeg
# 安装 Xvfb (视频号发布)
sudo apt install xvfb
# 安装 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
```
---
@@ -99,6 +114,8 @@ pip install -r requirements.txt
playwright install chromium
```
> 提示:视频号发布建议使用系统 Chrome + xvfb-run避免 headless 解码失败)。
---
### 可选AI 标题/标签生成
@@ -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) |
---
@@ -193,6 +218,12 @@ source venv/bin/activate
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)
```bash
@@ -227,9 +258,18 @@ python -m scripts.server
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
#!/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
```

View File

@@ -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
- 上传前转码为兼容 MP4H.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
View 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`

View File

@@ -264,6 +264,13 @@ import { formatDate } from '@/shared/lib/media';
---
## 发布页交互规则
- 发布按钮在未选择任何平台时禁用
- 仅保留“立即发布”,不再提供定时发布 UI/参数
---
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/shared/api/axios'`

View File

@@ -27,7 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
- **定时任务**: 支持 "立即发布" 或 "定时发布"
- **发布方式**: 支持 "立即发布"。
### 3. 声音克隆 [Day 13 新增]
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。

View File

@@ -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 优先,不可用自动回退内存。
@@ -95,6 +105,7 @@
### 🔴 优先待办
- [ ] **批量生成架构**: 支持 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)

View File

@@ -26,8 +26,10 @@
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
- 📱 **全自动发布** - 支持抖音/B站/小红书定时发布,微信视频号预留配置;扫码登录 + Cookie 持久化。
- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。

View File

@@ -7,11 +7,43 @@ 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
# 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"
MAX_UPLOAD_SIZE_MB: int = 500

View File

@@ -18,13 +18,15 @@ async def login_helper_page(platform: str, request: Request):
platform_urls = {
"bilibili": "https://www.bilibili.com/",
"douyin": "https://creator.douyin.com/",
"xiaohongshu": "https://creator.xiaohongshu.com/"
"xiaohongshu": "https://creator.xiaohongshu.com/",
"weixin": "https://channels.weixin.qq.com/"
}
platform_names = {
"bilibili": "B站",
"douyin": "抖音",
"xiaohongshu": "小红书"
"xiaohongshu": "小红书",
"weixin": "微信视频号"
}
if platform not in platform_urls:

View File

@@ -2,12 +2,16 @@
发布管理 API (支持用户认证)
"""
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]:
@@ -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")

View File

@@ -18,6 +18,7 @@ from app.services.storage import storage_service
from .uploader.bilibili_uploader import BilibiliUploader
from .uploader.douyin_uploader import DouyinUploader
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},
}
@@ -181,7 +182,8 @@ class PublishService:
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
description=description
description=description,
user_id=user_id,
)
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
@@ -192,6 +194,16 @@ class PublishService:
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 {

View File

@@ -3,12 +3,14 @@ QR码自动登录服务
后端Playwright无头模式获取二维码前端扫码后自动保存Cookie
"""
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 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:
@@ -61,9 +63,102 @@ class QRLoginService:
"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]:
"""
启动登录流程
@@ -80,22 +175,41 @@ class QRLoginService:
# 1. 启动 Playwright (不使用 async with手动管理生命周期)
self.playwright = await async_playwright().start()
# Stealth模式启动浏览器
self.browser = await self.playwright.chromium.launch(
headless=True,
args=[
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)
# 配置真实浏览器特征
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'
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')
urls_to_try = [config["url"]]
if self.platform == "weixin":
urls_to_try = [
"https://channels.weixin.qq.com/platform/",
"https://channels.weixin.qq.com/",
]
# 等待页面加载 (缩短等待)
await asyncio.sleep(2)
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,15 +306,26 @@ class QRLoginService:
if qr_element:
break
else:
# 其他平台 (小红书等):保持原顺序 CSS -> Text
# 其他平台 (小红书/微信等):保持原顺序 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:
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): 匹配成功")
qr_element = el
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}")
@@ -196,6 +333,9 @@ class QRLoginService:
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:
try:
@@ -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:
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 img
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}

View File

@@ -5,5 +5,6 @@ from .base_uploader import BaseUploader
from .bilibili_uploader import BilibiliUploader
from .douyin_uploader import DouyinUploader
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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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