diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md
index 8cb210b..5039aa5 100644
--- a/Docs/BACKEND_DEV.md
+++ b/Docs/BACKEND_DEV.md
@@ -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/
diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md
index b886f1c..2114b5f 100644
--- a/Docs/BACKEND_README.md
+++ b/Docs/BACKEND_README.md
@@ -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`: 字幕样式列表
diff --git a/Docs/DEPLOY_MANUAL.md b/Docs/DEPLOY_MANUAL.md
index 5208b81..7c3a8f8 100644
--- a/Docs/DEPLOY_MANUAL.md
+++ b/Docs/DEPLOY_MANUAL.md
@@ -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)
diff --git a/Docs/DevLogs/Day18.md b/Docs/DevLogs/Day18.md
index 0e5c802..4bb4e54 100644
--- a/Docs/DevLogs/Day18.md
+++ b/Docs/DevLogs/Day18.md
@@ -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`
diff --git a/Docs/DevLogs/Day19.md b/Docs/DevLogs/Day19.md
new file mode 100644
index 0000000..2287069
--- /dev/null
+++ b/Docs/DevLogs/Day19.md
@@ -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`
diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md
index e68045e..1f2b5a4 100644
--- a/Docs/FRONTEND_DEV.md
+++ b/Docs/FRONTEND_DEV.md
@@ -264,6 +264,13 @@ import { formatDate } from '@/shared/lib/media';
---
+## 发布页交互规则
+
+- 发布按钮在未选择任何平台时禁用
+- 仅保留“立即发布”,不再提供定时发布 UI/参数
+
+---
+
## 新增页面 Checklist
1. [ ] 导入 `import api from '@/shared/api/axios'`
diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md
index a06d218..6f13e64 100644
--- a/Docs/FRONTEND_README.md
+++ b/Docs/FRONTEND_README.md
@@ -27,7 +27,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
- **发布配置**: 设置视频标题、标签、简介。
- **作品选择**: 卡片列表 + 搜索 + 预览弹窗。
- **预览兼容**: 签名 URL / 相对路径均可直接预览。
-- **定时任务**: 支持 "立即发布" 或 "定时发布"。
+- **发布方式**: 仅支持 "立即发布"。
### 3. 声音克隆 [Day 13 新增]
- **TTS 模式选择**: EdgeTTS (预设音色) / 声音克隆 (自定义音色) 切换。
diff --git a/Docs/task_complete.md b/Docs/task_complete.md
index 5b91f4a..38a1ab9 100644
--- a/Docs/task_complete.md
+++ b/Docs/task_complete.md
@@ -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)
diff --git a/README.md b/README.md
index 27a3174..4bc9b3e 100644
--- a/README.md
+++ b/README.md
@@ -26,10 +26,12 @@
- 🤖 **AI 辅助创作** - 内置 GLM-4.7-Flash,支持 B站/抖音链接文案提取、AI 洗稿、标题/标签自动生成。
### 平台化功能
-- 📱 **全自动发布** - 支持抖音/B站/小红书定时发布,微信视频号预留配置;扫码登录 + Cookie 持久化。
+- 📱 **全自动发布** - 支持抖音/微信视频号/B站/小红书立即发布;扫码登录 + Cookie 持久化。
- 🖥️ **发布管理预览** - 支持签名 URL / 相对路径作品预览,确保可直接播放。
+- 📸 **发布结果可视化** - 抖音/微信视频号发布成功后返回截图,发布页结果卡片可直接查看。
+- 🛡️ **发布防误操作** - 发布进行中自动提示“请勿刷新或关闭网页”,并拦截刷新/关页二次确认。
- 🔐 **认证与隔离** - 基于 Supabase 的用户隔离,支持手机号注册/登录、密码管理。
-- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
+- 🛡️ **服务守护** - 内置 Watchdog 看门狗机制,自动监控并重启僵死服务,确保 7x24h 稳定运行。
- 🚀 **性能优化** - 视频预压缩、模型常驻服务(近实时加载)、双 GPU 流水线并发。
---
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index b26bc9a..edc37e0 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -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"
diff --git a/backend/app/modules/login_helper/router.py b/backend/app/modules/login_helper/router.py
index 4f05638..9c85800 100644
--- a/backend/app/modules/login_helper/router.py
+++ b/backend/app/modules/login_helper/router.py
@@ -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 "
不支持的平台
"
diff --git a/backend/app/modules/publish/router.py b/backend/app/modules/publish/router.py
index d433872..63b4c94 100644
--- a/backend/app/modules/publish/router.py
+++ b/backend/app/modules/publish/router.py
@@ -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")
diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py
index 416a167..938ab12 100644
--- a/backend/app/services/publish_service.py
+++ b/backend/app/services/publish_service.py
@@ -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,
diff --git a/backend/app/services/qr_login_service.py b/backend/app/services/qr_login_service.py
index b1ed461..dffbdaf 100644
--- a/backend/app/services/qr_login_service.py
+++ b/backend/app/services/qr_login_service.py
@@ -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}
diff --git a/backend/app/services/uploader/__init__.py b/backend/app/services/uploader/__init__.py
index 18b25f0..1b51c93 100644
--- a/backend/app/services/uploader/__init__.py
+++ b/backend/app/services/uploader/__init__.py
@@ -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']
diff --git a/backend/app/services/uploader/douyin_uploader.py b/backend/app/services/uploader/douyin_uploader.py
index 8b14e3c..e5358d9 100644
--- a/backend/app/services/uploader/douyin_uploader.py
+++ b/backend/app/services/uploader/douyin_uploader.py
@@ -6,13 +6,17 @@ from datetime import datetime
from pathlib import Path
from typing import Optional, List, Dict, Any
import asyncio
+import os
+import re
+import shutil
import time
-from playwright.async_api import Playwright, async_playwright
+from playwright.async_api import Playwright, async_playwright, TimeoutError as PlaywrightTimeoutError
from loguru import logger
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
+from app.core.config import settings
class DouyinUploader(BaseUploader):
@@ -21,6 +25,8 @@ class DouyinUploader(BaseUploader):
# 超时配置 (秒)
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 180 # 发布检测超时
+ PUBLISH_BUTTON_TIMEOUT = 60 # 等待发布按钮可点超时
+ POST_UPLOAD_STAGE_TIMEOUT = 60 # 上传完成后到发布结果的总超时
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
POLL_INTERVAL = 2 # 轮询间隔
MAX_CLICK_RETRIES = 3 # 按钮点击重试次数
@@ -32,10 +38,359 @@ class DouyinUploader(BaseUploader):
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
- description: str = ""
+ description: str = "",
+ user_id: Optional[str] = None,
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
+ self.user_id = user_id
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
+ self._temp_upload_paths: List[Path] = []
+ self._video_upload_committed = False
+ self._cover_generated = False
+ self._cover_gen_count = 0
+ self._publish_api_error: Optional[str] = None
+
+ def _resolve_headless_mode(self) -> str:
+ mode = (settings.DOUYIN_HEADLESS_MODE or "").strip().lower()
+ return mode or "headless-new"
+
+ def _build_launch_options(self) -> Dict[str, Any]:
+ mode = self._resolve_headless_mode()
+ args = [
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-blink-features=AutomationControlled",
+ ]
+
+ headless = mode not in ("headful", "false", "0", "no")
+ if headless and mode in ("new", "headless-new", "headless_new"):
+ args.append("--headless=new")
+
+ if settings.DOUYIN_FORCE_SWIFTSHADER or headless:
+ args.extend([
+ "--enable-unsafe-swiftshader",
+ "--use-gl=swiftshader",
+ ])
+
+ options: Dict[str, Any] = {"headless": headless, "args": args}
+ chrome_path = (settings.DOUYIN_CHROME_PATH or "").strip()
+ if chrome_path:
+ if Path(chrome_path).exists():
+ options["executable_path"] = chrome_path
+ else:
+ logger.warning(f"[抖音] DOUYIN_CHROME_PATH 不存在: {chrome_path}")
+ else:
+ channel = (settings.DOUYIN_BROWSER_CHANNEL or "").strip()
+ if channel:
+ options["channel"] = channel
+
+ return options
+
+ def _debug_log_path(self) -> Path:
+ debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots"
+ debug_dir.mkdir(exist_ok=True)
+ return debug_dir / "douyin_network.log"
+
+ def _debug_artifacts_enabled(self) -> bool:
+ return bool(settings.DEBUG and settings.DOUYIN_DEBUG_ARTIFACTS)
+
+ def _record_video_enabled(self) -> bool:
+ return bool(self._debug_artifacts_enabled() and settings.DOUYIN_RECORD_VIDEO)
+
+ def _append_debug_log(self, message: str) -> None:
+ if not self._debug_artifacts_enabled():
+ return
+ try:
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ log_path = self._debug_log_path()
+ with log_path.open("a", encoding="utf-8") as handle:
+ handle.write(f"[{timestamp}] {message}\n")
+ except Exception:
+ pass
+
+ def _video_record_dir(self) -> Path:
+ debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots" / "videos"
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ return debug_dir
+
+ async def _save_recorded_video(self, video, success: bool) -> Optional[Path]:
+ if not self._record_video_enabled():
+ return None
+ if not video:
+ return None
+
+ try:
+ timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
+ status = "success" if success else "failed"
+ file_path = self._video_record_dir() / f"douyin_{timestamp}_{status}.webm"
+ await video.save_as(str(file_path))
+ self._append_debug_log(f"[douyin][record] saved={file_path}")
+
+ if success and not settings.DOUYIN_KEEP_SUCCESS_VIDEO:
+ try:
+ file_path.unlink(missing_ok=True)
+ except TypeError:
+ if file_path.exists():
+ file_path.unlink()
+ self._append_debug_log("[douyin][record] removed_success_video")
+ return None
+
+ return file_path
+ except Exception as e:
+ logger.warning(f"[抖音] 保存录屏失败: {e}")
+ return None
+
+ async def _save_debug_screenshot(self, page, name: str) -> None:
+ if not self._debug_artifacts_enabled():
+ return
+ try:
+ debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots"
+ debug_dir.mkdir(exist_ok=True)
+ file_path = debug_dir / f"douyin_{name}.png"
+ await page.screenshot(path=str(file_path), full_page=True)
+ except Exception as e:
+ logger.warning(f"[抖音] 保存截图失败: {e}")
+
+ def _publish_screenshot_dir(self) -> Path:
+ user_key = re.sub(r"[^A-Za-z0-9_-]", "_", self.user_id or "legacy")[:64] or "legacy"
+ target = settings.PUBLISH_SCREENSHOT_DIR / user_key
+ target.mkdir(parents=True, exist_ok=True)
+ return target
+
+ async def _open_manage_review_tab(self, page) -> tuple[bool, str]:
+ manage_url = "https://creator.douyin.com/creator-micro/content/manage"
+ try:
+ if "content/manage" not in page.url:
+ await page.goto(manage_url)
+ await page.wait_for_load_state("domcontentloaded")
+ await asyncio.sleep(1.5)
+
+ review_selectors = [
+ "button:has-text('审核中')",
+ "div[role='tab']:has-text('审核中')",
+ "span:has-text('审核中')",
+ "button:has-text('待审核')",
+ "div[role='tab']:has-text('待审核')",
+ "span:has-text('待审核')",
+ ]
+
+ for selector in review_selectors:
+ target = await self._first_visible_locator(page.locator(selector), timeout=1200)
+ if not target:
+ continue
+ try:
+ await target.scroll_into_view_if_needed(timeout=1000)
+ except Exception:
+ pass
+ try:
+ await target.click(timeout=2500)
+ except Exception:
+ await target.evaluate("el => el.click()")
+ await asyncio.sleep(1.0)
+ self._append_debug_log(f"[douyin][manage] review_tab_clicked selector={selector}")
+ return True, selector
+
+ text_click_targets = [
+ page.get_by_text("审核中", exact=False).first,
+ page.get_by_text("待审核", exact=False).first,
+ ]
+ for target in text_click_targets:
+ try:
+ if not await target.is_visible(timeout=800):
+ continue
+ try:
+ await target.scroll_into_view_if_needed(timeout=1000)
+ except Exception:
+ pass
+ try:
+ await target.click(timeout=2000)
+ except Exception:
+ await target.evaluate("el => el.click()")
+ await asyncio.sleep(1.0)
+ self._append_debug_log("[douyin][manage] review_tab_clicked selector=text")
+ return True, "text"
+ except Exception:
+ continue
+
+ return False, "review-tab-not-found"
+ except Exception as e:
+ return False, f"open-manage-error:{e}"
+
+ async def _save_publish_success_screenshot(self, page) -> Optional[str]:
+ try:
+ timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
+ filename = f"douyin_success_review_{timestamp}_{int(time.time() * 1000) % 1000:03d}.png"
+ file_path = self._publish_screenshot_dir() / filename
+ opened, reason = await self._open_manage_review_tab(page)
+ if not opened:
+ self._append_debug_log(f"[douyin][manage] review_tab_open_failed reason={reason}")
+ return None
+
+ title_text = self.title[:30]
+ title_found = False
+ for _ in range(2):
+ deadline = time.time() + 8
+ while time.time() < deadline:
+ try:
+ title_locator = page.get_by_text(title_text, exact=False).first
+ if await title_locator.is_visible(timeout=500):
+ title_found = True
+ break
+ except Exception:
+ pass
+ await asyncio.sleep(0.4)
+ if title_found:
+ break
+ # 审核中列表有延迟,刷新后重查一次
+ await page.reload(wait_until="domcontentloaded")
+ await asyncio.sleep(1.0)
+ opened, _ = await self._open_manage_review_tab(page)
+ if not opened:
+ break
+
+ self._append_debug_log(
+ f"[douyin][manage] review_screenshot title_found={title_found} title={title_text}"
+ )
+ await page.screenshot(path=str(file_path), full_page=False)
+ return f"/api/publish/screenshot/{filename}"
+ except Exception as e:
+ logger.warning(f"[抖音] 保存发布成功截图失败: {e}")
+ return None
+
+ def _track_temp_upload_path(self, path: Path) -> None:
+ if path not in self._temp_upload_paths:
+ self._temp_upload_paths.append(path)
+
+ def _prepare_upload_file(self) -> Path:
+ src = self.file_path
+ if src.suffix:
+ return src
+
+ parent_suffix = Path(src.parent.name).suffix
+ if not parent_suffix:
+ logger.warning(f"[抖音] 上传文件缺少扩展名,且无法从父目录推断后缀: {src}")
+ self._append_debug_log(f"[douyin][upload_file_prepare] no suffix source={src}")
+ return src
+
+ temp_dir = Path("/tmp/vigent_uploads")
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ target = temp_dir / src.parent.name
+
+ try:
+ if target.exists():
+ target.unlink()
+ except Exception:
+ pass
+
+ try:
+ os.link(src, target)
+ except Exception:
+ shutil.copy2(src, target)
+
+ self._track_temp_upload_path(target)
+ logger.info(f"[抖音] 使用临时上传文件: {target}")
+ self._append_debug_log(f"[douyin][upload_file_prepare] source={src} prepared={target}")
+ return target
+
+ def _cleanup_upload_file(self) -> None:
+ if not self._temp_upload_paths:
+ return
+ paths = list(self._temp_upload_paths)
+ self._temp_upload_paths = []
+ for path in paths:
+ try:
+ if path.exists():
+ path.unlink()
+ except Exception as e:
+ logger.warning(f"[抖音] 清理临时上传文件失败: {e}")
+
+ def _should_log_request(self, request) -> bool:
+ try:
+ if request.resource_type not in ("xhr", "fetch"):
+ return False
+ if request.method not in ("POST", "PUT"):
+ return False
+ return True
+ except Exception:
+ return False
+
+ def _attach_debug_listeners(self, page) -> None:
+ def log_console(msg):
+ try:
+ if not self._debug_artifacts_enabled():
+ return
+ if msg.type in ("error", "warning"):
+ text = f"[douyin][console] {msg.type}: {msg.text}"
+ logger.warning(text)
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_page_error(err):
+ try:
+ if not self._debug_artifacts_enabled():
+ return
+ text = f"[douyin][pageerror] {err}"
+ logger.warning(text)
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_request_failed(request):
+ try:
+ if not self._debug_artifacts_enabled():
+ return
+ failure = request.failure
+ error_text = failure.error_text if failure else "unknown"
+ if self._should_log_request(request):
+ text = f"[douyin][requestfailed] {request.method} {request.url} -> {error_text}"
+ logger.warning(text)
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_request(request):
+ try:
+ if not self._debug_artifacts_enabled():
+ return
+ if self._should_log_request(request):
+ text = f"[douyin][request] {request.method} {request.url}"
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_response(response):
+ try:
+ request = response.request
+ if self._debug_artifacts_enabled() and (self._should_log_request(request) or response.status >= 400):
+ text = f"[douyin][response] {response.status} {request.method} {response.url}"
+ logger.warning(text)
+ self._append_debug_log(text)
+
+ if response.status < 400:
+ url = response.url
+ if "vod.bytedanceapi.com/" in url and "Action=CommitUploadInner" in url:
+ self._video_upload_committed = True
+ self._append_debug_log("[douyin][upload_signal] commit_upload_inner_ok")
+ if "/aweme/v1/cover/gen/post" in url:
+ self._cover_generated = True
+ self._cover_gen_count += 1
+ self._append_debug_log("[douyin][upload_signal] cover_gen_ok")
+ else:
+ url = response.url
+ if "/web/api/media/aweme/create_v2/" in url or "/aweme/create_v2/" in url:
+ self._publish_api_error = f"发布请求失败(HTTP {response.status})"
+ self._append_debug_log(
+ f"[douyin][publish_api_error] status={response.status} endpoint=create_v2"
+ )
+ except Exception:
+ pass
+
+ page.on("console", log_console)
+ page.on("pageerror", log_page_error)
+ page.on("requestfailed", log_request_failed)
+ page.on("request", log_request)
+ page.on("response", log_response)
async def _is_text_visible(self, page, text: str, exact: bool = False) -> bool:
try:
@@ -54,10 +409,345 @@ class DouyinUploader(BaseUploader):
return None
return None
+ async def _open_upload_page(self, page) -> None:
+ home_url = "https://creator.douyin.com/creator-micro/home"
+ clicked_high_quality = False
+ try:
+ await page.goto(home_url)
+ await page.wait_for_load_state("domcontentloaded")
+ await asyncio.sleep(1)
+ for selector in [
+ "button:has-text('高清发布')",
+ "a:has-text('高清发布')",
+ "div[role='button']:has-text('高清发布')",
+ ]:
+ try:
+ button = await self._first_visible_locator(page.locator(selector), timeout=1200)
+ if not button:
+ continue
+ await button.click()
+ logger.info(f"[抖音] 已点击上传入口: {selector}")
+ clicked_high_quality = True
+ await asyncio.sleep(1.5)
+ break
+ except Exception:
+ continue
+ except Exception as e:
+ logger.warning(f"[抖音] 打开首页失败,将直接进入上传页: {e}")
+
+ if "content/upload" not in page.url:
+ await page.goto(self.upload_url)
+ await page.wait_for_load_state("domcontentloaded")
+ await asyncio.sleep(1)
+ elif clicked_high_quality:
+ await asyncio.sleep(1)
+
+ async def _is_login_page(self, page) -> bool:
+ try:
+ current_url = page.url or ""
+ lower_url = current_url.lower()
+ if any(token in lower_url for token in ("passport", "login", "check_qrconnect", "sso")):
+ return True
+
+ text_hints = [
+ "扫码登录",
+ "验证码登录",
+ "密码登录",
+ "立即登录",
+ "登录后",
+ "抖音APP扫码登录",
+ "创作者登录",
+ "我想MCN机构",
+ ]
+ for text in text_hints:
+ if await self._is_text_visible(page, text, exact=False):
+ return True
+
+ selector_hints = [
+ "input[placeholder*='手机号']",
+ "input[placeholder*='验证码']",
+ "button:has-text('立即登录')",
+ "button:has-text('登录')",
+ ]
+ for selector in selector_hints:
+ try:
+ target = await self._first_visible_locator(page.locator(selector), timeout=600)
+ if target:
+ return True
+ except Exception:
+ continue
+ except Exception:
+ return False
+
+ return False
+
+ async def _handle_unfinished_draft(self, page) -> None:
+ if not await self._is_text_visible(page, "你还有上次未发布的视频", exact=False):
+ return
+
+ logger.info("[抖音] 检测到未发布草稿,尝试放弃后重新上传")
+ clicked = False
+ for selector in [
+ "button:has-text('放弃')",
+ "a:has-text('放弃')",
+ "span:has-text('放弃')",
+ ]:
+ try:
+ target = await self._first_visible_locator(page.locator(selector), timeout=1200)
+ if not target:
+ continue
+ await target.click()
+ clicked = True
+ logger.info(f"[抖音] 已点击草稿放弃按钮: {selector}")
+ await asyncio.sleep(0.8)
+ break
+ except Exception:
+ continue
+
+ if not clicked:
+ return
+
+ for selector in [
+ "button:has-text('确认放弃')",
+ "button:has-text('确认')",
+ "button:has-text('确定')",
+ ]:
+ try:
+ confirm_button = await self._first_visible_locator(page.locator(selector), timeout=800)
+ if confirm_button:
+ await confirm_button.click()
+ logger.info(f"[抖音] 已确认放弃草稿: {selector}")
+ await asyncio.sleep(0.6)
+ break
+ except Exception:
+ continue
+
+ async def _is_publish_form_ready(self, page) -> bool:
+ current_url = page.url
+ if "content/publish" in current_url or "content/post/video" in current_url:
+ return True
+
+ for text in ["基础信息", "作品描述", "发布设置", "重新上传"]:
+ if await self._is_text_visible(page, text, exact=False):
+ return True
+
+ for selector in [
+ "button:has-text('发布')",
+ "button:has-text('定时发布')",
+ ]:
+ try:
+ button = await self._first_visible_locator(page.locator(selector), timeout=600)
+ if button:
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ async def _wait_for_publish_form_ready(self, page, timeout: int = 60) -> bool:
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ if await self._is_publish_form_ready(page):
+ return True
+ await asyncio.sleep(0.5)
+ return False
+
+ async def _is_upload_completed(self, page) -> bool:
+ in_progress = await self._is_upload_in_progress(page)
+
+ reupload_visible = await self._is_text_visible(page, "重新上传", exact=False)
+ preview_video_visible = await self._is_text_visible(page, "预览视频", exact=False)
+ preview_cover_visible = await self._is_text_visible(page, "预览封面/标题", exact=False)
+
+ # 页面已出现“重新上传 + 预览”并且视频提交信号已到,优先认为可进入封面步骤
+ # 避免“处理中”文案残留导致长时间不开始封面设置
+ if reupload_visible and (preview_video_visible or preview_cover_visible) and self._video_upload_committed:
+ self._append_debug_log("[douyin][upload_ready] visual_done_reupload_preview_commit")
+ return True
+
+ if reupload_visible and (preview_video_visible or preview_cover_visible) and not in_progress:
+ self._append_debug_log("[douyin][upload_ready] visual_done_reupload_preview")
+ return True
+
+ if reupload_visible and not in_progress:
+ self._append_debug_log("[douyin][upload_ready] visual_done_reupload")
+ return True
+
+ if (preview_video_visible or preview_cover_visible) and self._video_upload_committed and not in_progress:
+ if self._cover_generated:
+ self._append_debug_log("[douyin][upload_ready] visual_done_commit_and_cover")
+ else:
+ self._append_debug_log("[douyin][upload_ready] visual_done_commit")
+ return True
+
+ return False
+
+ async def _is_upload_in_progress(self, page) -> bool:
+ if await self._is_text_visible(page, "上传过程中请不要刷新", exact=False):
+ return True
+
+ if await self._is_text_visible(page, "取消上传", exact=False):
+ return True
+
+ progress_hints = ["已上传", "当前速度", "剩余时间", "上传中", "处理中", "转码中", "封面生成"]
+ hit = 0
+ for text in progress_hints:
+ if await self._is_text_visible(page, text, exact=False):
+ hit += 1
+ return hit >= 2
+
+ async def _wait_for_upload_completion(self, page, timeout: int) -> bool:
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ try:
+ if await self._is_upload_completed(page):
+ return True
+
+ elapsed = int(time.time() - start_time)
+ if await self._is_upload_in_progress(page):
+ logger.info(f"[抖音] 视频上传进行中...({elapsed}s)")
+ self._append_debug_log(f"[douyin][upload_wait] in_progress elapsed={elapsed}")
+ else:
+ self._append_debug_log(f"[douyin][upload_wait] waiting_signals elapsed={elapsed}")
+ except Exception:
+ pass
+
+ await asyncio.sleep(1)
+
+ return False
+
+ async def _find_publish_button(self, page, publish_label: str):
+ selectors = [
+ page.get_by_role("button", name=publish_label, exact=True).first,
+ page.get_by_role("button", name=publish_label, exact=False).first,
+ page.locator(f"button:has-text('{publish_label}')").first,
+ ]
+
+ if publish_label == "发布":
+ selectors.extend([
+ page.locator("button:has-text('发布')").first,
+ page.locator("button:has-text('立即发布')").first,
+ ])
+ else:
+ selectors.append(page.locator("button:has-text('定时发布')").first)
+
+ for button in selectors:
+ try:
+ if await button.is_visible(timeout=600):
+ return button
+ except Exception:
+ continue
+ return None
+
+ async def _click_publish_button(self, page, publish_label: str, timeout: Optional[float] = None) -> tuple[bool, str]:
+ start_time = time.time()
+ last_error = ""
+ wait_timeout = timeout if timeout is not None else self.PUBLISH_BUTTON_TIMEOUT
+
+ while time.time() - start_time < wait_timeout:
+ await self._dismiss_blocking_modal(page)
+ button = await self._find_publish_button(page, publish_label)
+
+ if button:
+ try:
+ if await button.is_enabled():
+ self._append_debug_log("[douyin][publish_click] try=primary")
+ await button.click(timeout=5000)
+ self._append_debug_log(f"[douyin][publish_click] clicked label={publish_label}")
+ logger.info(f"[抖音] 点击了{publish_label}按钮")
+ return True, ""
+ self._append_debug_log(f"[douyin][publish_wait] disabled label={publish_label}")
+ except Exception as e:
+ last_error = str(e)
+
+ fallback_selectors = ["button:has-text('发布')", "button:has-text('定时发布')"]
+ for selector in fallback_selectors:
+ try:
+ target = await self._first_visible_locator(page.locator(selector), timeout=500)
+ if not target:
+ continue
+ if not await target.is_enabled():
+ continue
+ self._append_debug_log(f"[douyin][publish_click] try=fallback selector={selector}")
+ await target.click(timeout=3000)
+ self._append_debug_log(f"[douyin][publish_click] clicked selector={selector}")
+ logger.info(f"[抖音] 使用备用选择器点击了按钮: {selector}")
+ return True, ""
+ except Exception as e:
+ last_error = str(e)
+ continue
+
+ elapsed = int(time.time() - start_time)
+ logger.info(f"[抖音] 等待{publish_label}按钮可点击...({elapsed}s)")
+ self._append_debug_log(f"[douyin][publish_wait] label={publish_label} elapsed={elapsed}")
+ await asyncio.sleep(1)
+
+ return False, last_error or "发布按钮未进入可点击状态"
+
+ async def _set_input_file_and_log(self, locator, upload_path: Path) -> None:
+ await locator.set_input_files(str(upload_path))
+ size = upload_path.stat().st_size
+ info = f"[douyin][upload_file] path={upload_path} size={size} suffix={upload_path.suffix}"
+ logger.info(info)
+ self._append_debug_log(info)
+ file_info = await locator.evaluate(
+ """
+ (input) => {
+ const file = input && input.files ? input.files[0] : null;
+ if (!file) return null;
+ return { name: file.name, size: file.size, type: file.type };
+ }
+ """
+ )
+ if file_info:
+ text = f"[douyin][file_input] name={file_info.get('name')} size={file_info.get('size')} type={file_info.get('type')}"
+ else:
+ text = "[douyin][file_input] empty"
+ logger.info(text)
+ self._append_debug_log(text)
+
+ async def _try_upload_via_file_chooser(self, page, upload_path: Path) -> bool:
+ for selector in [
+ "button:has-text('上传视频')",
+ "span:has-text('上传视频')",
+ "div:has-text('上传视频')",
+ ]:
+ try:
+ trigger = await self._first_visible_locator(page.locator(selector), timeout=1200)
+ if not trigger:
+ continue
+ self._append_debug_log(f"[douyin][upload_trigger] filechooser selector={selector}")
+ async with page.expect_file_chooser(timeout=5000) as chooser_info:
+ await trigger.click()
+ chooser = await chooser_info.value
+ await chooser.set_files(str(upload_path))
+ size = upload_path.stat().st_size
+ self._append_debug_log(
+ f"[douyin][upload_file] path={upload_path} size={size} suffix={upload_path.suffix} mode=filechooser"
+ )
+ if await self._wait_for_publish_form_ready(page, timeout=20):
+ return True
+ except PlaywrightTimeoutError:
+ continue
+ except Exception:
+ continue
+ return False
+
async def _wait_for_publish_result(self, page, max_wait_time: int = 180):
success_texts = ["发布成功", "作品已发布", "再发一条", "查看作品", "审核中", "待审核"]
weak_texts = ["发布完成"]
- failure_texts = ["发布失败", "发布异常", "发布出错", "请完善", "请补充", "请先上传"]
+ failure_texts = [
+ "发布失败",
+ "发布异常",
+ "发布出错",
+ "请完善",
+ "请补充",
+ "请先上传",
+ "网络不佳",
+ "网络异常",
+ "网络开小差",
+ "请稍后重试",
+ ]
start_time = time.time()
poll_interval = 2
weak_reason = None
@@ -66,6 +756,9 @@ class DouyinUploader(BaseUploader):
if page.is_closed():
return False, "页面已关闭", False
+ if self._publish_api_error:
+ return False, self._publish_api_error, False
+
current_url = page.url
if "content/manage" in current_url:
return True, f"已跳转到管理页面 (URL: {current_url})", False
@@ -90,25 +783,174 @@ class DouyinUploader(BaseUploader):
return False, "发布检测超时", True
+ async def _is_cover_required(self, page) -> bool:
+ try:
+ if await page.get_by_text("设置封面", exact=False).count() == 0:
+ return False
+
+ # 老版页面通常会明确展示 "必填"
+ if await page.get_by_text("必填", exact=False).count() > 0:
+ self._append_debug_log("[douyin][cover] required_by_text")
+ return True
+
+ return False
+ except Exception:
+ return False
+
+ async def _confirm_cover_selection(self, page, wait_enabled_timeout: float = 6.0) -> bool:
+ confirm_selectors = [
+ "button:has-text('完成')",
+ "button:has-text('确定')",
+ "button:has-text('保存')",
+ "button:has-text('确认')",
+ ]
+
+ for selector in confirm_selectors:
+ try:
+ button = await self._first_visible_locator(page.locator(selector), timeout=600)
+ if not button:
+ continue
+
+ deadline = time.time() + wait_enabled_timeout
+ while time.time() < deadline:
+ try:
+ if await button.is_enabled():
+ break
+ except Exception:
+ break
+ await asyncio.sleep(0.2)
+
+ try:
+ if not await button.is_enabled():
+ continue
+ except Exception:
+ continue
+
+ await button.click()
+ logger.info(f"[抖音] 封面已确认: {selector}")
+ self._append_debug_log(f"[douyin][cover] confirmed selector={selector}")
+ await asyncio.sleep(0.3)
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ async def _switch_to_horizontal_cover(self, scopes, timeout: float = 5.0) -> tuple[bool, bool]:
+ selectors = [
+ "button:has-text('设置横封面')",
+ "div:has-text('设置横封面')",
+ "span:has-text('设置横封面')",
+ "button:has-text('横封面')",
+ "div[role='tab']:has-text('横封面')",
+ "span:has-text('横封面')",
+ "button:has-text('横版封面')",
+ "span:has-text('横版封面')",
+ ]
+
+ available = False
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ for scope in scopes:
+ for selector in selectors:
+ try:
+ target = await self._first_visible_locator(scope.locator(selector), timeout=700)
+ if not target:
+ continue
+ available = True
+ try:
+ await target.scroll_into_view_if_needed(timeout=1000)
+ except Exception:
+ pass
+ try:
+ await target.click(timeout=2000)
+ except Exception:
+ await target.evaluate("el => el.click()")
+
+ logger.info(f"[抖音] 已切换到横封面设置: {selector}")
+ self._append_debug_log(f"[douyin][cover] switched_horizontal selector={selector}")
+ await asyncio.sleep(0.2)
+ return True, True
+ except Exception:
+ continue
+ await asyncio.sleep(0.25)
+
+ return False, available
+
+ async def _wait_for_cover_effect_pass(self, page, baseline_cover_count: int, timeout: float = 12.0) -> bool:
+ pass_texts = [
+ "封面效果检测通过",
+ "封面效果检测已通过",
+ "封面检测通过",
+ "检测通过",
+ ]
+
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if self._cover_gen_count > baseline_cover_count:
+ self._append_debug_log("[douyin][cover] effect_passed_by_cover_gen")
+ return True
+
+ for text in pass_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ self._append_debug_log(f"[douyin][cover] effect_passed_by_text={text}")
+ return True
+
+ await asyncio.sleep(0.25)
+
+ self._append_debug_log("[douyin][cover] effect_check_timeout")
+ return False
+
+ async def _is_cover_configured_on_page(self, page) -> bool:
+ try:
+ if await page.get_by_text("设置封面", exact=False).count() == 0:
+ return False
+
+ has_horizontal_label = await self._is_text_visible(page, "横封面", exact=False)
+ has_vertical_label = await self._is_text_visible(page, "竖封面", exact=False)
+ if not has_horizontal_label and not has_vertical_label:
+ return False
+
+ selectors = [
+ "div:has-text('竖封面') img",
+ "div:has-text('横封面') img",
+ "div[class*='cover'] img",
+ ]
+ for selector in selectors:
+ try:
+ locator = page.locator(selector)
+ count = await locator.count()
+ for idx in range(min(count, 6)):
+ try:
+ if await locator.nth(idx).is_visible(timeout=400):
+ self._append_debug_log(f"[douyin][cover] configured_visual selector={selector}")
+ return True
+ except Exception:
+ continue
+ except Exception:
+ continue
+
+ # 某些页面不会给图片节点明显 class,兜底以标签+无“选择封面”判断
+ has_choose_cover = await page.get_by_text("选择封面", exact=False).count() > 0
+ if not has_choose_cover and has_horizontal_label and has_vertical_label:
+ self._append_debug_log("[douyin][cover] configured_by_labels_no_choose_button")
+ return True
+ except Exception:
+ return False
+
+ return False
+
async def _fill_title(self, page, title: str) -> bool:
title_text = title[:30]
- locator_candidates = []
-
- try:
- label_locator = page.get_by_text("作品描述").locator("..").locator("..").locator(
- "xpath=following-sibling::div[1]"
- ).locator("textarea, input, div[contenteditable='true']")
- locator_candidates.append(label_locator)
- except Exception:
- pass
-
- locator_candidates.extend([
+ locator_candidates = [
+ page.locator("input[placeholder*='填写作品标题']"),
+ page.locator("input[placeholder*='作品标题']"),
+ page.locator("textarea[placeholder*='填写作品标题']"),
page.locator("textarea[placeholder*='作品描述']"),
- page.locator("textarea[placeholder*='描述']"),
page.locator("input[placeholder*='作品描述']"),
+ page.locator("textarea[placeholder*='描述']"),
page.locator("input[placeholder*='描述']"),
- page.locator("div[contenteditable='true']"),
- ])
+ ]
for locator in locator_candidates:
try:
@@ -119,92 +961,163 @@ class DouyinUploader(BaseUploader):
except Exception:
continue
+ try:
+ editable = page.locator("div[contenteditable='true']").first
+ if await editable.count() > 0:
+ await editable.click()
+ await page.keyboard.press("Control+KeyA")
+ await page.keyboard.press("Delete")
+ await page.keyboard.type(title_text)
+ return True
+ except Exception:
+ pass
+
return False
- async def _select_cover_if_needed(self, page) -> bool:
+ async def _fill_description_with_tags(self, page) -> bool:
+ tags = [tag.strip().lstrip("#") for tag in self.tags if tag and tag.strip()]
+ if not tags:
+ return True
+
+ tag_text = " ".join(f"#{tag}" for tag in tags)
+ locator_candidates = [
+ page.locator("textarea[placeholder*='添加作品简介']"),
+ page.locator("textarea[placeholder*='作品简介']"),
+ page.locator("textarea[placeholder*='简介']"),
+ page.locator("div[contenteditable='true'][data-placeholder*='添加作品简介']"),
+ page.locator("div[contenteditable='true'][placeholder*='添加作品简介']"),
+ page.locator(".zone-container"),
+ ]
+
+ for locator in locator_candidates:
+ try:
+ if await locator.count() == 0:
+ continue
+ target = locator.first
+ await target.click()
+ try:
+ current_value = await target.input_value()
+ value = (current_value or "").strip()
+ merged = f"{value} {tag_text}".strip()
+ await target.fill(merged)
+ except Exception:
+ await page.keyboard.type(tag_text + " ")
+ logger.info(f"[抖音] 已添加话题: {tag_text}")
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ async def _select_cover_if_needed(self, page, require_horizontal: bool = False) -> bool:
try:
+ started_at = time.time()
+ baseline_cover_count = self._cover_gen_count
cover_button = page.get_by_text("选择封面", exact=False).first
- if await cover_button.is_visible():
- await cover_button.click()
- logger.info("[抖音] 尝试选择封面")
- await asyncio.sleep(0.5)
+ if not await cover_button.is_visible():
+ if await self._is_cover_configured_on_page(page):
+ self._append_debug_log("[douyin][cover] already_configured_no_choose_button")
+ if require_horizontal:
+ await self._wait_for_cover_effect_pass(page, baseline_cover_count, timeout=3.0)
+ return True
+ self._append_debug_log("[douyin][cover] no_cover_button")
+ return False
- dialog = page.locator(
- "div.dy-creator-content-modal-wrap, div[role='dialog'], "
- "div[class*='modal'], div[class*='dialog']"
- ).last
- scopes = [dialog] if await dialog.count() > 0 else [page]
+ await cover_button.click()
+ logger.info("[抖音] 尝试选择封面")
+ await asyncio.sleep(0.3)
- switched = False
- for scope in scopes:
- for selector in [
- "button:has-text('设置横封面')",
- "div:has-text('设置横封面')",
- "span:has-text('设置横封面')",
- ]:
- try:
- button = await self._first_visible_locator(scope.locator(selector))
- if button:
- await button.click()
- logger.info("[抖音] 已切换到横封面设置")
- await asyncio.sleep(0.5)
- switched = True
- break
- except Exception:
- continue
- if switched:
- break
+ dialog = page.locator(
+ "div.dy-creator-content-modal-wrap, div[role='dialog'], "
+ "div[class*='modal'], div[class*='dialog']"
+ ).last
+ scopes = [dialog] if await dialog.count() > 0 else [page]
- selected = False
- for scope in scopes:
- for selector in [
- "div[class*='cover'] img",
- "div[class*='cover']",
- "div[class*='frame'] img",
- "div[class*='frame']",
- "div[class*='preset']",
- "img",
- ]:
- try:
- candidate = await self._first_visible_locator(scope.locator(selector))
- if candidate:
- await candidate.click()
- logger.info("[抖音] 已选择封面帧")
- selected = True
- break
- except Exception:
- continue
- if selected:
- break
+ await self._dismiss_blocking_modal(page)
- confirm_selectors = [
- "button:has-text('完成')",
- "button:has-text('确定')",
- "button:has-text('保存')",
- "button:has-text('确认')",
- ]
- for selector in confirm_selectors:
+ switched, horizontal_available = await self._switch_to_horizontal_cover(scopes)
+ if horizontal_available and not switched:
+ # 弹窗可能遮挡了横封面入口,再尝试一次
+ await self._dismiss_blocking_modal(page)
+ switched, _ = await self._switch_to_horizontal_cover(scopes)
+
+ if horizontal_available and not switched:
+ if await self._is_cover_configured_on_page(page):
+ self._append_debug_log("[douyin][cover] horizontal_missed_but_already_configured")
+ await self._wait_for_cover_effect_pass(page, baseline_cover_count, timeout=3.0)
+ return True
+ self._append_debug_log("[douyin][cover] horizontal_switch_missed")
+ return False
+
+ if require_horizontal and not switched:
+ self._append_debug_log("[douyin][cover] required_horizontal_not_switched")
+ return False
+
+ # 某些旧版页面切到横封面后会默认选中,可直接完成
+ if switched and await self._confirm_cover_selection(page, wait_enabled_timeout=1.5):
+ if await dialog.count() > 0:
try:
- button = await self._first_visible_locator(page.locator(selector))
- if button:
- if not await button.is_enabled():
- for _ in range(8):
- if await button.is_enabled():
- break
- await asyncio.sleep(0.5)
- await button.click()
- logger.info(f"[抖音] 封面已确认: {selector}")
- await asyncio.sleep(0.5)
- if await dialog.count() > 0:
- try:
- await dialog.wait_for(state="hidden", timeout=5000)
- except Exception:
- pass
- return True
+ await dialog.wait_for(state="hidden", timeout=4000)
+ except Exception:
+ pass
+ if not await self._wait_for_cover_effect_pass(page, baseline_cover_count, timeout=12.0):
+ return False
+ elapsed = int(time.time() - started_at)
+ self._append_debug_log(f"[douyin][cover] fast_confirm_after_switch elapsed={elapsed}")
+ return True
+
+ # 没有横封面入口时,尝试直接确认(兼容其它页面形态)
+ if not require_horizontal and not horizontal_available and await self._confirm_cover_selection(page, wait_enabled_timeout=1.5):
+ if await dialog.count() > 0:
+ try:
+ await dialog.wait_for(state="hidden", timeout=4000)
+ except Exception:
+ pass
+ elapsed = int(time.time() - started_at)
+ self._append_debug_log(f"[douyin][cover] fast_confirm elapsed={elapsed}")
+ return True
+
+ selected = False
+ for scope in scopes:
+ for selector in [
+ "div[class*='cover'] img",
+ "div[class*='cover']",
+ "div[class*='frame'] img",
+ "div[class*='frame']",
+ "div[class*='preset']",
+ ]:
+ try:
+ candidate = await self._first_visible_locator(scope.locator(selector))
+ if candidate:
+ await candidate.click()
+ logger.info("[抖音] 已选择封面帧")
+ selected = True
+ break
except Exception:
continue
+ if selected:
+ break
- return selected
+ if not selected:
+ self._append_debug_log("[douyin][cover] no_cover_candidate")
+ return False
+
+ confirmed = await self._confirm_cover_selection(page, wait_enabled_timeout=8.0)
+ if confirmed and await dialog.count() > 0:
+ try:
+ await dialog.wait_for(state="hidden", timeout=5000)
+ except Exception:
+ pass
+
+ if confirmed and (switched or require_horizontal):
+ if not await self._wait_for_cover_effect_pass(page, baseline_cover_count, timeout=12.0):
+ return False
+
+ elapsed = int(time.time() - started_at)
+ self._append_debug_log(
+ f"[douyin][cover] selected={selected} confirmed={confirmed} elapsed={elapsed}"
+ )
+ return confirmed
except Exception as e:
logger.warning(f"[抖音] 选择封面失败: {e}")
return False
@@ -250,6 +1163,7 @@ class DouyinUploader(BaseUploader):
"确认",
"同意并继续",
"完成",
+ "暂不设置",
"好的",
"明白了",
]
@@ -292,20 +1206,17 @@ class DouyinUploader(BaseUploader):
return False
async def _verify_publish_in_manage(self, page):
- manage_url = "https://creator.douyin.com/creator-micro/content/manage"
try:
- await page.goto(manage_url)
- await page.wait_for_load_state("domcontentloaded")
- await asyncio.sleep(2)
+ opened, reason = await self._open_manage_review_tab(page)
+ if not opened:
+ self._append_debug_log(f"[douyin][manage] verify_open_review_failed reason={reason}")
title_text = self.title[:30]
title_locator = page.get_by_text(title_text, exact=False).first
if await title_locator.is_visible():
- return True, "内容管理中检测到新作品"
- if await self._is_text_visible(page, "审核中", exact=False):
- return True, "内容管理显示审核中"
+ return True, "审核中列表检测到新作品"
except Exception as e:
return False, f"无法验证内容管理: {e}"
- return False, "内容管理中未找到视频"
+ return False, "审核中列表未找到视频"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
@@ -334,76 +1245,93 @@ class DouyinUploader(BaseUploader):
"""Main upload logic with guaranteed resource cleanup"""
browser = None
context = None
+ page = None
+ recorded_video = None
+ final_success = False
try:
- # Launch browser in headless mode for server deployment
- browser = await playwright.chromium.launch(headless=True)
- context = await browser.new_context(storage_state=self.account_file)
+ launch_options = self._build_launch_options()
+ browser = await playwright.chromium.launch(**launch_options)
+ context_kwargs = {
+ "storage_state": self.account_file,
+ "viewport": {"width": 1920, "height": 1080},
+ "user_agent": settings.DOUYIN_USER_AGENT,
+ "locale": settings.DOUYIN_LOCALE,
+ "timezone_id": settings.DOUYIN_TIMEZONE_ID,
+ }
+ if self._record_video_enabled():
+ context_kwargs["record_video_dir"] = str(self._video_record_dir())
+ context_kwargs["record_video_size"] = {
+ "width": settings.DOUYIN_RECORD_VIDEO_WIDTH,
+ "height": settings.DOUYIN_RECORD_VIDEO_HEIGHT,
+ }
+ context = await browser.new_context(
+ **context_kwargs,
+ )
context = await set_init_script(context)
page = await context.new_page()
+ recorded_video = page.video
+ self._attach_debug_listeners(page)
- # Go to upload page
- await page.goto(self.upload_url)
- await page.wait_for_load_state('domcontentloaded')
- await asyncio.sleep(2)
-
- logger.info(f"[抖音] 正在上传: {self.file_path.name}")
-
- # Check if redirected to login page (more reliable than text detection)
+ await self._open_upload_page(page)
+
+ upload_path = self._prepare_upload_file()
+ logger.info(f"[抖音] 正在上传: {upload_path.name}")
+
current_url = page.url
- if "login" in current_url or "passport" in current_url:
+ if "login" in current_url or "passport" in current_url or await self._is_login_page(page):
logger.error("[抖音] Cookie 已失效,被重定向到登录页")
+ self._append_debug_log(f"[douyin][auth] login_required url={current_url}")
+ await self._save_debug_screenshot(page, "login_redirect")
return {
"success": False,
"message": "Cookie 已失效,请重新登录",
"url": None
}
-
- # Ensure we're on the upload page
- if "content/upload" not in page.url:
- logger.info("[抖音] 当前不在上传页面,强制跳转...")
- await page.goto(self.upload_url)
- await asyncio.sleep(2)
-
- # Try multiple selectors for the file input (page structure varies)
- file_uploaded = False
- selectors = [
- "div[class^='container'] input", # Primary selector from SuperIPAgent
- "input[type='file']", # Fallback selector
- "div[class^='upload'] input[type='file']", # Alternative
- ]
-
- for selector in selectors:
- try:
- logger.info(f"[抖音] 尝试选择器: {selector}")
- locator = page.locator(selector).first
- if await locator.count() > 0:
- await locator.set_input_files(str(self.file_path))
- file_uploaded = True
- logger.info(f"[抖音] 文件上传成功使用选择器: {selector}")
- break
- except Exception as e:
- logger.warning(f"[抖音] 选择器 {selector} 失败: {e}")
- continue
-
- if not file_uploaded:
- logger.error("[抖音] 所有选择器都失败,无法上传文件")
+
+ await self._handle_unfinished_draft(page)
+
+ self._video_upload_committed = False
+ self._cover_generated = False
+ upload_ok = await self._try_upload_via_file_chooser(page, upload_path)
+ if not upload_ok:
+ self._append_debug_log("[douyin][upload_flow] filechooser_not_triggered_first_try")
+
+ if not upload_ok:
+ logger.error("[抖音] 文件已选择但页面未进入发布态,按首页流程重试一次")
+ await self._save_debug_screenshot(page, "upload_not_accepted_before_retry")
+
+ await self._open_upload_page(page)
+ await self._handle_unfinished_draft(page)
+
+ self._video_upload_committed = False
+ self._cover_generated = False
+ upload_ok = await self._try_upload_via_file_chooser(page, upload_path)
+ if not upload_ok:
+ self._append_debug_log("[douyin][upload_flow] filechooser_not_triggered_second_try")
+
+ if not upload_ok:
+ if await self._is_login_page(page):
+ logger.error("[抖音] 上传阶段检测到登录页,Cookie 已失效")
+ self._append_debug_log(f"[douyin][auth] login_required_after_upload_retry url={page.url}")
+ await self._save_debug_screenshot(page, "login_redirect")
+ return {
+ "success": False,
+ "message": "Cookie 已失效,请重新登录",
+ "url": None
+ }
+
+ logger.error("[抖音] 上传未生效,页面结构可能已变化")
+ await self._save_debug_screenshot(page, "publish_page_timeout")
return {
"success": False,
- "message": "无法找到上传按钮,页面可能已更新",
+ "message": "未触发上传视频文件选择弹窗,无法进入发布页面",
"url": None
}
-
- # Wait for redirect to publish page (with timeout)
- redirect_start = time.time()
- while time.time() - redirect_start < self.PAGE_REDIRECT_TIMEOUT:
- current_url = page.url
- if "content/publish" in current_url or "content/post/video" in current_url:
- logger.info("[抖音] 成功进入发布页面")
- break
- await asyncio.sleep(0.5)
- else:
+
+ if not await self._wait_for_publish_form_ready(page, timeout=self.PAGE_REDIRECT_TIMEOUT):
logger.error("[抖音] 等待发布页面超时")
+ await self._save_debug_screenshot(page, "publish_page_timeout")
return {
"success": False,
"message": "等待发布页面超时",
@@ -416,115 +1344,125 @@ class DouyinUploader(BaseUploader):
if not await self._fill_title(page, self.title):
logger.warning("[抖音] 未找到作品描述输入框")
-
- # Add tags
- css_selector = ".zone-container"
- for tag in self.tags:
- await page.type(css_selector, "#" + tag)
- await page.press(css_selector, "Space")
-
- logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
- cover_selected = await self._select_cover_if_needed(page)
- if not cover_selected:
- logger.warning("[抖音] 未确认封面选择,可能影响发布")
-
- # Wait for upload to complete (with timeout)
- upload_start = time.time()
- while time.time() - upload_start < self.UPLOAD_TIMEOUT:
- try:
- number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
- if number > 0:
- logger.success("[抖音] 视频上传完毕")
- break
- else:
- logger.info("[抖音] 正在上传视频中...")
- await asyncio.sleep(self.POLL_INTERVAL)
- except Exception:
- await asyncio.sleep(self.POLL_INTERVAL)
- else:
- logger.error("[抖音] 视频上传超时")
- return {
- "success": False,
- "message": "视频上传超时",
- "url": None
- }
-
+ if not await self._fill_description_with_tags(page):
+ logger.warning("[抖音] 未找到简介输入框,无法自动填充话题")
+
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
+
+ if not await self._wait_for_upload_completion(page, timeout=self.UPLOAD_TIMEOUT):
+ logger.error("[抖音] 视频上传完成检测超时")
+ await self._save_debug_screenshot(page, "upload_timeout")
+ return {
+ "success": False,
+ "message": "视频上传未完成(超时),未执行发布",
+ "url": None
+ }
+
+ post_upload_deadline = time.time() + self.POST_UPLOAD_STAGE_TIMEOUT
+ self._append_debug_log(
+ f"[douyin][post_upload] stage_timeout={self.POST_UPLOAD_STAGE_TIMEOUT}s"
+ )
+
+ cover_required = await self._is_cover_required(page)
+ if cover_required:
+ logger.info("[抖音] 当前页面封面为必填,开始设置封面")
+ cover_selected = False
+ for attempt in range(2):
+ cover_selected = await self._select_cover_if_needed(page, require_horizontal=True)
+ if cover_selected:
+ break
+ await asyncio.sleep(1)
+
+ if not cover_selected and await self._is_cover_configured_on_page(page):
+ logger.warning("[抖音] 封面流程返回失败,但页面已显示封面配置,继续发布")
+ self._append_debug_log("[douyin][cover] configured_fallback_continue")
+ cover_selected = True
+
+ if cover_selected:
+ logger.info("[抖音] 封面设置完成")
+ self._append_debug_log("[douyin][cover] selected")
+ else:
+ logger.error("[抖音] 封面设置未完成,停止发布")
+ self._append_debug_log("[douyin][cover] not_selected")
+ await self._save_debug_screenshot(page, "cover_not_selected")
+ return {
+ "success": False,
+ "message": "封面为必填但未设置成功,已停止发布",
+ "url": None
+ }
+ else:
+ logger.info("[抖音] 当前页面封面非必填,跳过封面设置")
+ self._append_debug_log("[douyin][cover] optional_skip")
+
+ logger.info("[抖音] 检测到上传完成,等待2秒后再点击发布")
+ self._append_debug_log("[douyin][upload_ready] wait_before_publish=2s")
+ await asyncio.sleep(2)
+
+ remaining = post_upload_deadline - time.time()
+ if remaining <= 0:
+ return {
+ "success": False,
+ "message": "上传完成后发布阶段超时(超过60秒)",
+ "url": None
+ }
# Click publish button
- # 使用更稳健的点击逻辑
- try:
- publish_label = "定时发布" if self.publish_date != 0 else "发布"
- publish_button = page.get_by_role('button', name=publish_label, exact=True)
- # 等待按钮出现
- await publish_button.wait_for(state="visible", timeout=10000)
- if not await publish_button.is_enabled():
- logger.error("[抖音] 发布按钮不可点击,可能需要补充封面或确认信息")
- return {
- "success": False,
- "message": "发布按钮不可点击,请检查封面/声明等必填项",
- "url": None
- }
- await asyncio.sleep(1) # 额外等待以确保可交互
-
- clicked = False
- for attempt in range(self.MAX_CLICK_RETRIES):
- await self._dismiss_blocking_modal(page)
- try:
- await publish_button.click(timeout=5000)
- logger.info(f"[抖音] 点击了{publish_label}按钮")
- clicked = True
- break
- except Exception as click_error:
- logger.warning(f"[抖音] 点击发布按钮失败,重试 {attempt + 1}/{self.MAX_CLICK_RETRIES}: {click_error}")
- try:
- await page.keyboard.press("Escape")
- except Exception:
- pass
+ publish_label = "定时发布" if self.publish_date != 0 else "发布"
+ self._publish_api_error = None
+ click_timeout = max(5, min(self.PUBLISH_BUTTON_TIMEOUT, remaining))
+ clicked, reason = await self._click_publish_button(page, publish_label, timeout=click_timeout)
+ if not clicked and not cover_required:
+ logger.warning("[抖音] 首次点击发布失败,尝试先设置封面后重试")
+ self._append_debug_log("[douyin][cover] retry_select_before_publish")
+ for _ in range(2):
+ if await self._select_cover_if_needed(page, require_horizontal=False):
await asyncio.sleep(1)
-
- if not clicked:
- raise RuntimeError("点击发布按钮失败")
- except Exception as e:
- logger.error(f"[抖音] 点击发布按钮失败: {e}")
- # 尝试备用选择器
- try:
- fallback_selectors = ["button:has-text('发布')", "button:has-text('定时发布')"]
- clicked = False
- for selector in fallback_selectors:
- try:
- await page.click(selector, timeout=5000)
- logger.info(f"[抖音] 使用备用选择器点击了按钮: {selector}")
- clicked = True
+ remaining = post_upload_deadline - time.time()
+ if remaining <= 0:
break
- except Exception:
- continue
+ click_timeout = max(5, min(self.PUBLISH_BUTTON_TIMEOUT, remaining))
+ clicked, reason = await self._click_publish_button(
+ page,
+ publish_label,
+ timeout=click_timeout,
+ )
+ if clicked:
+ self._append_debug_log("[douyin][cover] retry_click_success")
+ break
+ await asyncio.sleep(1)
- if not clicked:
- return {
- "success": False,
- "message": "无法点击发布按钮,请检查页面状态",
- "url": None
- }
- except Exception:
- return {
- "success": False,
- "message": "无法点击发布按钮,请检查页面状态",
- "url": None
- }
+ if not clicked:
+ logger.error(f"[抖音] 点击发布按钮失败: {reason}")
+ await self._save_debug_screenshot(page, "publish_button_disabled")
+ return {
+ "success": False,
+ "message": "发布按钮长时间不可点击或点击失败,请检查声明/权限/页面弹窗",
+ "url": None
+ }
await self._click_publish_confirm_modal(page)
# 4. 检测发布完成
- publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(page)
+ remaining = int(post_upload_deadline - time.time())
+ if remaining <= 0:
+ return {
+ "success": False,
+ "message": "上传完成后发布阶段超时(超过60秒)",
+ "url": None
+ }
+
+ publish_wait_timeout = max(5, min(self.PUBLISH_TIMEOUT, remaining))
+ publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(
+ page,
+ max_wait_time=publish_wait_timeout,
+ )
if not publish_success and is_timeout:
verify_success, verify_reason = await self._verify_publish_in_manage(page)
if verify_success:
- publish_success = True
- publish_reason = verify_reason
+ publish_reason = f"{publish_reason}; 管理页检测到同名作品: {verify_reason}"
else:
publish_reason = f"{publish_reason}; {verify_reason}"
if publish_success:
@@ -539,18 +1477,24 @@ class DouyinUploader(BaseUploader):
await context.storage_state(path=self.account_file)
logger.success("[抖音] Cookie 更新完毕")
- await asyncio.sleep(2)
-
if publish_success:
+ final_success = True
+ try:
+ await page.wait_for_load_state("domcontentloaded", timeout=5000)
+ except Exception:
+ pass
+ await asyncio.sleep(3)
+ screenshot_url = await self._save_publish_success_screenshot(page)
return {
"success": True,
"message": "发布成功,待审核",
- "url": None
+ "url": None,
+ "screenshot_url": screenshot_url,
}
if is_timeout:
return {
- "success": True,
- "message": "发布检测超时,请到抖音后台确认",
+ "success": False,
+ "message": f"发布状态未知(检测超时),请到抖音后台确认: {publish_reason}",
"url": None
}
return {
@@ -568,6 +1512,17 @@ class DouyinUploader(BaseUploader):
}
finally:
# 确保资源释放
+ video_path = None
+ if page:
+ try:
+ if not page.is_closed():
+ await page.close()
+ except Exception:
+ pass
+ video_path = await self._save_recorded_video(recorded_video, final_success)
+ if video_path:
+ logger.info(f"[抖音] 调试录屏已保存: {video_path}")
+
if context:
try:
await context.close()
@@ -578,6 +1533,7 @@ class DouyinUploader(BaseUploader):
await browser.close()
except Exception:
pass
+ self._cleanup_upload_file()
async def main(self) -> Dict[str, Any]:
"""Execute upload"""
diff --git a/backend/app/services/uploader/weixin_uploader.py b/backend/app/services/uploader/weixin_uploader.py
new file mode 100644
index 0000000..1544556
--- /dev/null
+++ b/backend/app/services/uploader/weixin_uploader.py
@@ -0,0 +1,1426 @@
+"""
+Weixin Channels uploader using Playwright.
+Best-effort selectors for upload and publish flows.
+"""
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, List, Dict, Any
+from uuid import uuid4
+import os
+import re
+import shutil
+import asyncio
+import time
+import subprocess
+
+from playwright.async_api import Playwright, async_playwright
+from loguru import logger
+
+from .base_uploader import BaseUploader
+from .cookie_utils import set_init_script
+from app.core.config import settings
+
+
+class WeixinUploader(BaseUploader):
+ """Weixin Channels video uploader using Playwright"""
+
+ UPLOAD_TIMEOUT = 420
+ PUBLISH_TIMEOUT = 90
+ PAGE_READY_TIMEOUT = 60
+ POLL_INTERVAL = 2
+ MAX_CLICK_RETRIES = 3
+
+ def __init__(
+ self,
+ title: str,
+ file_path: str,
+ tags: List[str],
+ publish_date: Optional[datetime] = None,
+ account_file: Optional[str] = None,
+ description: str = "",
+ user_id: Optional[str] = None,
+ ):
+ super().__init__(title, file_path, tags, publish_date, account_file, description)
+ self.user_id = user_id
+ self.upload_url = "https://channels.weixin.qq.com/platform"
+ self._temp_upload_paths: List[Path] = []
+ self._post_create_submitted = False
+ self._publish_api_error: Optional[str] = None
+
+ def _resolve_headless_mode(self) -> str:
+ mode = (settings.WEIXIN_HEADLESS_MODE or "").strip().lower()
+ return mode or "headful"
+
+ def _build_launch_options(self) -> Dict[str, Any]:
+ mode = self._resolve_headless_mode()
+ args = [
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-blink-features=AutomationControlled",
+ ]
+ headless = mode not in ("headful", "false", "0", "no")
+ if headless and mode in ("new", "headless-new", "headless_new"):
+ args.append("--headless=new")
+ if settings.WEIXIN_FORCE_SWIFTSHADER or headless:
+ args.extend([
+ "--enable-unsafe-swiftshader",
+ "--use-gl=swiftshader",
+ ])
+ options: Dict[str, Any] = {"headless": headless, "args": args}
+ chrome_path = (settings.WEIXIN_CHROME_PATH or "").strip()
+ if chrome_path:
+ if Path(chrome_path).exists():
+ 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:
+ options["channel"] = channel
+ return options
+
+ def _debug_log_path(self) -> Path:
+ debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots"
+ debug_dir.mkdir(exist_ok=True)
+ return debug_dir / "weixin_network.log"
+
+ def _debug_artifacts_enabled(self) -> bool:
+ return bool(settings.DEBUG and settings.WEIXIN_DEBUG_ARTIFACTS)
+
+ def _record_video_enabled(self) -> bool:
+ return bool(self._debug_artifacts_enabled() and settings.WEIXIN_RECORD_VIDEO)
+
+ def _append_debug_log(self, message: str) -> None:
+ if not self._debug_artifacts_enabled():
+ return
+ try:
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ log_path = self._debug_log_path()
+ with log_path.open("a", encoding="utf-8") as handle:
+ handle.write(f"[{timestamp}] {message}\n")
+ except Exception:
+ pass
+
+ def _is_upload_related_url(self, url: str) -> bool:
+ lowered = (url or "").lower()
+ keywords = (
+ "upload",
+ "media",
+ "video",
+ "vod",
+ "cos",
+ "qcloud",
+ "finder",
+ "weixin",
+ "channels",
+ )
+ return any(keyword in lowered for keyword in keywords)
+
+ def _should_log_request(self, request) -> bool:
+ try:
+ if request.resource_type not in ("xhr", "fetch"):
+ return False
+ if request.method not in ("POST", "PUT"):
+ return False
+ return True
+ except Exception:
+ return False
+
+ def _attach_debug_listeners(self, page) -> None:
+ if not self._debug_artifacts_enabled():
+ return
+
+ def log_console(msg):
+ try:
+ if msg.type in ("error", "warning"):
+ text = f"[weixin][console] {msg.type}: {msg.text}"
+ logger.warning(text)
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_page_error(err):
+ try:
+ text = f"[weixin][pageerror] {err}"
+ logger.warning(text)
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_request_failed(request):
+ try:
+ failure = request.failure
+ error_text = failure.error_text if failure else "unknown"
+ if self._should_log_request(request) or self._is_upload_related_url(request.url):
+ text = f"[weixin][requestfailed] {request.method} {request.url} -> {error_text}"
+ logger.warning(text)
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_request(request):
+ try:
+ if self._should_log_request(request):
+ text = f"[weixin][request] {request.method} {request.url}"
+ self._append_debug_log(text)
+ except Exception:
+ pass
+
+ def log_response(response):
+ try:
+ request = response.request
+ if self._should_log_request(request) or (response.status >= 400 and self._is_upload_related_url(response.url)):
+ text = f"[weixin][response] {response.status} {request.method} {response.url}"
+ logger.warning(text)
+ self._append_debug_log(text)
+
+ url = response.url or ""
+ if "/post/post_create" in url:
+ if response.status < 400:
+ self._post_create_submitted = True
+ self._append_debug_log("[weixin][publish] post_create_ok")
+ else:
+ self._publish_api_error = f"发布请求失败(HTTP {response.status})"
+ self._append_debug_log(f"[weixin][publish] post_create_failed status={response.status}")
+ except Exception:
+ pass
+
+ page.on("console", log_console)
+ page.on("pageerror", log_page_error)
+ page.on("requestfailed", log_request_failed)
+ page.on("request", log_request)
+ page.on("response", log_response)
+
+ async def _is_text_visible(self, page, text: str, exact: bool = False) -> bool:
+ try:
+ return await page.get_by_text(text, exact=exact).first.is_visible()
+ except Exception:
+ return False
+
+ async def _first_visible_locator(self, locator, timeout: int = 1000):
+ try:
+ if await locator.count() == 0:
+ return None
+ candidate = locator.first
+ if await candidate.is_visible(timeout=timeout):
+ return candidate
+ except Exception:
+ return None
+ return None
+
+ async def _is_login_page(self, page) -> bool:
+ url = (page.url or "").lower()
+ if "login" in url or "qrcode" in url or "qr" in url:
+ return True
+
+ login_texts = [
+ "\u626b\u7801\u767b\u5f55",
+ "\u5fae\u4fe1\u626b\u7801",
+ "\u8bf7\u4f7f\u7528\u5fae\u4fe1\u626b\u7801",
+ ]
+ for text in login_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ return True
+ return False
+
+ def _iter_scopes(self, page):
+ scopes = [page]
+ try:
+ for frame in page.frames:
+ if frame != page.main_frame:
+ scopes.append(frame)
+ except Exception:
+ pass
+ return scopes
+
+ async def _click_first_match(self, page, selectors: List[str], timeout: int = 1000) -> bool:
+ scopes = self._iter_scopes(page)
+ for scope in scopes:
+ for selector in selectors:
+ try:
+ locator = await self._first_visible_locator(scope.locator(selector), timeout=timeout)
+ if locator:
+ await locator.click()
+ await asyncio.sleep(0.5)
+ return True
+ except Exception:
+ continue
+ return False
+
+ async def _click_by_text(self, page, texts: List[str], allow_fuzzy: bool = True) -> bool:
+ scopes = self._iter_scopes(page)
+ for scope in scopes:
+ for text in texts:
+ for exact in (True, False):
+ if not exact and (not allow_fuzzy or len(text) < 3):
+ continue
+ try:
+ locator = scope.get_by_text(text, exact=exact).first
+ if await locator.is_visible():
+ await locator.click()
+ await asyncio.sleep(0.5)
+ return True
+ except Exception:
+ continue
+ return False
+
+ async def _scroll_down(self, page, steps: int = 4) -> None:
+ for _ in range(steps):
+ try:
+ await page.mouse.wheel(0, 900)
+ except Exception:
+ pass
+ try:
+ await page.keyboard.press("PageDown")
+ except Exception:
+ pass
+ await asyncio.sleep(0.3)
+
+ async def _scroll_to_bottom(self, scope) -> None:
+ try:
+ await scope.evaluate(
+ """
+ () => {
+ const scrollingElement = document.scrollingElement || document.documentElement;
+ if (scrollingElement) {
+ scrollingElement.scrollTop = scrollingElement.scrollHeight;
+ }
+ window.scrollTo(0, document.body.scrollHeight);
+ }
+ """
+ )
+ except Exception:
+ pass
+ try:
+ await scope.evaluate(
+ """
+ () => {
+ const candidates = Array.from(document.querySelectorAll('*'));
+ for (const el of candidates) {
+ const style = window.getComputedStyle(el);
+ const overflowY = style.overflowY || '';
+ const canScroll = el.scrollHeight > el.clientHeight && overflowY !== 'visible' && overflowY !== 'hidden';
+ if (canScroll) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }
+ }
+ """
+ )
+ except Exception:
+ pass
+
+ async def _save_debug_screenshot(self, page, name: str) -> None:
+ if not self._debug_artifacts_enabled():
+ return
+ try:
+ debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots"
+ debug_dir.mkdir(exist_ok=True)
+ safe_name = name.replace("/", "_").replace(" ", "_")
+ debug_path = debug_dir / f"weixin_{safe_name}.png"
+ await page.screenshot(path=str(debug_path), full_page=True)
+ logger.info(f"[weixin] saved debug screenshot: {debug_path}")
+ except Exception as e:
+ logger.warning(f"[weixin] failed to save debug screenshot: {e}")
+
+ def _video_record_dir(self) -> Path:
+ debug_dir = Path(__file__).parent.parent.parent / "debug_screenshots" / "videos"
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ return debug_dir
+
+ async def _save_recorded_video(self, video, success: bool) -> Optional[Path]:
+ if not self._record_video_enabled():
+ return None
+ if not video:
+ return None
+
+ try:
+ timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
+ status = "success" if success else "failed"
+ file_path = self._video_record_dir() / f"weixin_{timestamp}_{status}.webm"
+ await video.save_as(str(file_path))
+ self._append_debug_log(f"[weixin][record] saved={file_path}")
+
+ if success and not settings.WEIXIN_KEEP_SUCCESS_VIDEO:
+ try:
+ file_path.unlink(missing_ok=True)
+ except TypeError:
+ if file_path.exists():
+ file_path.unlink()
+ self._append_debug_log("[weixin][record] removed_success_video")
+ return None
+
+ return file_path
+ except Exception as e:
+ logger.warning(f"[weixin] 保存录屏失败: {e}")
+ return None
+
+ def _publish_screenshot_dir(self) -> Path:
+ user_key = re.sub(r"[^A-Za-z0-9_-]", "_", self.user_id or "legacy")[:64] or "legacy"
+ target = settings.PUBLISH_SCREENSHOT_DIR / user_key
+ target.mkdir(parents=True, exist_ok=True)
+ return target
+
+ async def _save_publish_success_screenshot(self, page) -> Optional[str]:
+ try:
+ timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
+ filename = f"weixin_success_{timestamp}_{int(time.time() * 1000) % 1000:03d}.png"
+ file_path = self._publish_screenshot_dir() / filename
+ await self._apply_page_zoom(page, zoom=1.0)
+ await page.screenshot(path=str(file_path), full_page=False)
+ return f"/api/publish/screenshot/{filename}"
+ except Exception as e:
+ logger.warning(f"[weixin] 保存发布成功截图失败: {e}")
+ return None
+
+ async def _apply_page_zoom(self, page, zoom: float = 0.8) -> None:
+ try:
+ await page.evaluate(
+ """
+ (z) => {
+ const value = String(z);
+ const current = document.documentElement.style.zoom || (document.body && document.body.style.zoom) || '';
+ if (window.__vigentZoomApplied === value && current === value) {
+ return;
+ }
+ document.documentElement.style.zoom = value;
+ if (document.body) {
+ document.body.style.zoom = value;
+ }
+ window.__vigentZoomApplied = value;
+ }
+ """,
+ zoom,
+ )
+ except Exception:
+ pass
+
+ async def _click_publish_via_dom(self, scope) -> bool:
+ try:
+ return await scope.evaluate(
+ """
+ () => {
+ const selectors = ['button', '[role="button"]', '[class*="btn"]', '[class*="button"]'];
+ const candidates = [];
+ for (const sel of selectors) {
+ candidates.push(...document.querySelectorAll(sel));
+ }
+ const texts = ['发表', '发布'];
+ for (const text of texts) {
+ for (const el of candidates) {
+ const content = (el.textContent || '').trim();
+ if (!content || !content.includes(text)) continue;
+ const disabled = !!(el.disabled || el.getAttribute('aria-disabled') === 'true' || el.classList.contains('disabled'));
+ if (disabled) continue;
+ if (typeof el.scrollIntoView === 'function') {
+ el.scrollIntoView({block: 'center', inline: 'center'});
+ }
+ el.click();
+ return true;
+ }
+ }
+ return false;
+ }
+ """
+ )
+ except Exception:
+ return False
+
+ def _track_temp_upload_path(self, path: Path) -> None:
+ if path not in self._temp_upload_paths:
+ self._temp_upload_paths.append(path)
+
+ def _new_temp_mp4_path(self) -> Path:
+ temp_dir = Path("/tmp/vigent_uploads")
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ return temp_dir / f"{uuid4().hex}.mp4"
+
+ def _run_ffmpeg(self, cmd: List[str], output_path: Path, label: str) -> Optional[Path]:
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ stderr = (result.stderr or "").strip()
+ logger.warning(f"[weixin] ffmpeg {label} failed: {stderr}")
+ self._append_debug_log(f"[weixin][ffmpeg] {label} failed: {stderr}")
+ try:
+ if output_path.exists():
+ output_path.unlink()
+ except Exception:
+ pass
+ return None
+
+ self._track_temp_upload_path(output_path)
+ logger.info(f"[weixin] ffmpeg {label} ok: {output_path}")
+ self._append_debug_log(f"[weixin][ffmpeg] {label} ok: {output_path}")
+ return output_path
+ except Exception as e:
+ logger.warning(f"[weixin] ffmpeg {label} error: {e}")
+ self._append_debug_log(f"[weixin][ffmpeg] {label} error: {e}")
+ try:
+ if output_path.exists():
+ output_path.unlink()
+ except Exception:
+ pass
+ return None
+
+ def _maybe_transcode_upload_file(self, src: Path) -> Path:
+ mode = (settings.WEIXIN_TRANSCODE_MODE or "").strip().lower()
+ if mode in ("", "off", "false", "0", "none"):
+ return src
+
+ if mode == "faststart":
+ output_path = self._new_temp_mp4_path()
+ cmd = [
+ "ffmpeg",
+ "-y",
+ "-i",
+ str(src),
+ "-c",
+ "copy",
+ "-movflags",
+ "+faststart",
+ str(output_path),
+ ]
+ result = self._run_ffmpeg(cmd, output_path, "faststart")
+ return result or src
+
+ if mode == "reencode":
+ output_path = self._new_temp_mp4_path()
+ cmd = [
+ "ffmpeg",
+ "-y",
+ "-i",
+ str(src),
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-profile:v",
+ "main",
+ "-pix_fmt",
+ "yuv420p",
+ "-r",
+ "30",
+ "-g",
+ "60",
+ "-keyint_min",
+ "60",
+ "-sc_threshold",
+ "0",
+ "-vf",
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
+ "-c:a",
+ "aac",
+ "-b:a",
+ "128k",
+ "-ar",
+ "44100",
+ "-movflags",
+ "+faststart",
+ str(output_path),
+ ]
+ result = self._run_ffmpeg(cmd, output_path, "reencode")
+ return result or src
+
+ logger.warning(f"[weixin] unknown WEIXIN_TRANSCODE_MODE: {mode}")
+ return src
+
+ def _prepare_upload_file(self) -> Path:
+ src = self.file_path
+ if src.suffix:
+ return self._maybe_transcode_upload_file(src)
+
+ parent_suffix = Path(src.parent.name).suffix
+ if not parent_suffix:
+ return self._maybe_transcode_upload_file(src)
+
+ temp_dir = Path("/tmp/vigent_uploads")
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ target = temp_dir / src.parent.name
+
+ try:
+ if target.exists():
+ target.unlink()
+ except Exception:
+ pass
+
+ try:
+ os.link(src, target)
+ except Exception:
+ try:
+ os.symlink(src, target)
+ except Exception:
+ shutil.copy2(src, target)
+
+ self._track_temp_upload_path(target)
+ logger.info(f"[weixin] using temp upload file: {target}")
+ return self._maybe_transcode_upload_file(target)
+
+ def _cleanup_upload_file(self) -> None:
+ if not self._temp_upload_paths:
+ return
+ paths = list(self._temp_upload_paths)
+ self._temp_upload_paths = []
+ for path in paths:
+ try:
+ if path.exists():
+ path.unlink()
+ except Exception as e:
+ logger.warning(f"[weixin] failed to cleanup temp upload file: {e}")
+
+ async def _try_click_publish_button(self, page, timeout: float = 6.0) -> bool:
+ selectors = [
+ "button:has-text('发表视频')",
+ "button:has-text('发布视频')",
+ "button:has-text('发表动态')",
+ "a:has-text('发表视频')",
+ "a:has-text('发表动态')",
+ "a[href*='post/create']",
+ "a[href*='post/create'] span",
+ "div[role='button']:has-text('发表视频')",
+ "div[role='button']:has-text('发表动态')",
+ ]
+ start_time = time.monotonic()
+ while time.monotonic() - start_time < timeout:
+ if await self._click_first_match(page, selectors, timeout=500):
+ return True
+ await asyncio.sleep(0.5)
+ return False
+
+ async def _wait_for_publish_panel(self, page, timeout: int = 10) -> bool:
+ start_time = time.monotonic()
+ while time.monotonic() - start_time < timeout:
+ if await self._is_text_visible(page, "发表动态", exact=False):
+ return True
+ if await self._is_text_visible(page, "视频描述", exact=False):
+ return True
+ if await self._find_file_input(page):
+ return True
+ await asyncio.sleep(0.5)
+ return False
+
+ async def _goto_publish_urls(self, page) -> bool:
+ candidates = [
+ "https://channels.weixin.qq.com/platform/post/create",
+ "https://channels.weixin.qq.com/platform/post/create?type=video",
+ "https://channels.weixin.qq.com/platform/post/create?tab=video",
+ "https://channels.weixin.qq.com/platform/post/create?from=post_list",
+ "https://channels.weixin.qq.com/platform/post/create?scene=video",
+ ]
+ for url in candidates:
+ try:
+ await page.goto(url, wait_until="domcontentloaded")
+ if await self._wait_for_publish_panel(page, timeout=6):
+ logger.info(f"[weixin] opened publish page: {url}")
+ return True
+ except Exception:
+ continue
+ return False
+
+ async def _click_and_maybe_switch_page(self, page, click_fn, timeout: float = 3.0):
+ wait_task = asyncio.create_task(page.context.wait_for_event("page"))
+ clicked = await click_fn()
+ if not clicked:
+ wait_task.cancel()
+ return False, page
+
+ new_page = None
+ try:
+ new_page = await asyncio.wait_for(wait_task, timeout=timeout)
+ except Exception:
+ wait_task.cancel()
+
+ if new_page:
+ try:
+ await new_page.wait_for_load_state("domcontentloaded")
+ except Exception:
+ pass
+ return True, new_page
+
+ return True, page
+
+ async def _go_to_publish_page(self, page):
+ if await self._wait_for_publish_panel(page, timeout=2):
+ await self._apply_page_zoom(page)
+ return page
+
+ logger.info("[weixin] 尝试进入发表视频页面...")
+
+ async def click_home_publish():
+ return await self._click_by_text(page, ["发表视频", "发布视频", "发表动态"], allow_fuzzy=True)
+
+ clicked, next_page = await self._click_and_maybe_switch_page(page, click_home_publish)
+ if clicked:
+ await self._apply_page_zoom(next_page)
+ if await self._wait_for_publish_panel(next_page, timeout=6):
+ return next_page
+
+ await self._click_by_text(page, ["视频号助手"], allow_fuzzy=True)
+
+ await self._click_by_text(page, ["内容管理"], allow_fuzzy=True)
+ await asyncio.sleep(0.5)
+ await self._click_by_text(page, ["视频"], allow_fuzzy=False)
+ await asyncio.sleep(1)
+
+ try:
+ await page.goto("https://channels.weixin.qq.com/platform/post/list", wait_until="domcontentloaded")
+ except Exception:
+ pass
+
+ await asyncio.sleep(0.5)
+
+ async def click_list_publish():
+ if await self._click_by_text(page, ["发表视频", "发布视频", "发表动态"], allow_fuzzy=True):
+ return True
+ return await self._try_click_publish_button(page, timeout=6)
+
+ clicked, next_page = await self._click_and_maybe_switch_page(page, click_list_publish)
+ if clicked:
+ await self._apply_page_zoom(next_page)
+ if await self._wait_for_publish_panel(next_page, timeout=6):
+ return next_page
+
+ if await self._goto_publish_urls(page):
+ await self._apply_page_zoom(page)
+ return page
+
+ logger.warning(f"[weixin] 进入发表页面失败,当前URL: {page.url}")
+ await self._apply_page_zoom(page)
+ return page
+
+ async def _open_upload_entry(self, page) -> bool:
+ selectors = [
+ "button:has-text('\\u53d1\\u5e03')",
+ "button:has-text('\\u53d1\\u8868')",
+ "button:has-text('\\u4e0a\\u4f20')",
+ "button:has-text('\\u53d1\\u89c6\\u9891')",
+ "button:has-text('\\u53d1\\u8868\\u89c6\\u9891')",
+ "button:has-text('\\u89c6\\u9891')",
+ "a:has-text('\\u53d1\\u5e03')",
+ "a:has-text('\\u53d1\\u8868')",
+ "a:has-text('\\u53d1\\u8868\\u89c6\\u9891')",
+ "div[role='button']:has-text('\\u53d1\\u8868\\u89c6\\u9891')",
+ ]
+ return await self._click_first_match(page, selectors)
+
+ async def _find_file_input(self, page):
+ selectors = [
+ "input[type='file'][accept*='video']",
+ "input[type='file'][accept*='mp4']",
+ "input[type='file']",
+ ]
+ scopes = self._iter_scopes(page)
+ for scope in scopes:
+ for selector in selectors:
+ try:
+ locator = scope.locator(selector)
+ if await locator.count() > 0:
+ return locator.first
+ except Exception:
+ continue
+ return None
+
+ async def _click_upload_card(self, page) -> bool:
+ selectors = [
+ "div:has-text('上传视频')",
+ "div:has-text('上传时长')",
+ "div:has-text('MP4')",
+ "div:has-text('H.264')",
+ "div:has-text('上传')",
+ ]
+ return await self._click_first_match(page, selectors)
+
+ async def _try_file_chooser_upload(self, page, upload_path: Path) -> bool:
+ try:
+ async with page.expect_file_chooser(timeout=2500) as chooser_info:
+ clicked = await self._click_upload_card(page)
+ if not clicked:
+ await self._open_upload_entry(page)
+ await self._click_upload_card(page)
+ file_chooser = await chooser_info.value
+ await file_chooser.set_files(str(upload_path))
+ info = f"[weixin][file_chooser] used path={upload_path}"
+ logger.info(info)
+ self._append_debug_log(info)
+ return True
+ except Exception:
+ return False
+
+ async def _is_upload_placeholder_visible(self, page) -> bool:
+ selectors = [
+ "div:has-text('上传时长')",
+ "div:has-text('码率')",
+ "div:has-text('MP4')",
+ "div:has-text('H.264')",
+ ]
+ scopes = self._iter_scopes(page)
+ for scope in scopes:
+ for selector in selectors:
+ try:
+ locator = scope.locator(selector).first
+ if await locator.count() > 0 and await locator.is_visible():
+ return True
+ except Exception:
+ continue
+ return False
+
+ async def _has_enabled_publish_button(self, page) -> bool:
+ selectors = [
+ "button:has-text('发表')",
+ "button:has-text('发布')",
+ "div[role='button']:has-text('发表')",
+ "a:has-text('发表')",
+ ]
+ scopes = self._iter_scopes(page)
+ for scope in scopes:
+ await self._scroll_to_bottom(scope)
+ for selector in selectors:
+ try:
+ locator = scope.locator(selector)
+ count = await locator.count()
+ for i in range(count):
+ candidate = locator.nth(i)
+ try:
+ await candidate.scroll_into_view_if_needed()
+ except Exception:
+ pass
+ if await candidate.is_visible() and await candidate.is_enabled():
+ return True
+ except Exception:
+ continue
+ return False
+
+ async def _upload_video(self, page) -> bool:
+ page = await self._go_to_publish_page(page)
+ await self._save_debug_screenshot(page, "publish_page")
+ for attempt in range(self.MAX_CLICK_RETRIES):
+ input_locator = await self._find_file_input(page)
+ if not input_locator:
+ await self._open_upload_entry(page)
+ await self._click_upload_card(page)
+ await asyncio.sleep(1)
+ input_locator = await self._find_file_input(page)
+
+ try:
+ upload_path = self._prepare_upload_file()
+ try:
+ size = upload_path.stat().st_size
+ info = f"[weixin][upload_file] path={upload_path} size={size} suffix={upload_path.suffix}"
+ logger.info(info)
+ self._append_debug_log(info)
+ except Exception as e:
+ logger.warning(f"[weixin] failed to stat upload file: {e}")
+
+ if await self._try_file_chooser_upload(page, upload_path):
+ return True
+
+ if input_locator:
+ await input_locator.set_input_files(str(upload_path))
+ try:
+ file_info = await input_locator.evaluate(
+ """
+ (input) => {
+ const file = input && input.files ? input.files[0] : null;
+ if (!file) return null;
+ return { name: file.name, size: file.size, type: file.type };
+ }
+ """
+ )
+ if file_info:
+ text = f"[weixin][file_input] name={file_info.get('name')} size={file_info.get('size')} type={file_info.get('type')}"
+ logger.info(text)
+ self._append_debug_log(text)
+ return True
+ text = "[weixin][file_input] empty"
+ logger.warning(text)
+ self._append_debug_log(text)
+ await asyncio.sleep(0.5)
+ if await self._is_upload_in_progress(page):
+ logger.info("[weixin] upload started after file input set")
+ return True
+ except Exception as e:
+ logger.warning(f"[weixin] failed to read file input info: {e}")
+ except Exception as e:
+ logger.warning(f"[weixin] set_input_files failed: {e}")
+
+ await asyncio.sleep(1)
+ logger.info(f"[weixin] retrying file input ({attempt + 1}/{self.MAX_CLICK_RETRIES})")
+
+ logger.warning(f"[weixin] file input not found, url: {page.url}")
+ await self._save_debug_screenshot(page, "upload_input_missing")
+
+ return False
+
+ async def _is_upload_in_progress(self, page) -> bool:
+ progress_texts = [
+ "取消上传",
+ "上传中",
+ "正在上传",
+ "生成中",
+ "文件上传中",
+ "请稍后点击发布",
+ ]
+ for text in progress_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ return True
+ return False
+
+ async def _wait_for_upload_complete(self, page):
+ success_texts = [
+ "\u4e0a\u4f20\u6210\u529f",
+ "\u4e0a\u4f20\u5b8c\u6210",
+ "\u5df2\u4e0a\u4f20",
+ "\u5904\u7406\u5b8c\u6210",
+ "\u8f6c\u7801\u5b8c\u6210",
+ ]
+ failure_texts = [
+ "\u4e0a\u4f20\u5931\u8d25",
+ "\u4e0a\u4f20\u5f02\u5e38",
+ "\u4e0a\u4f20\u51fa\u9519",
+ "\u4e0a\u4f20\u8d85\u65f6",
+ "\u7f51\u7edc\u9519\u8bef",
+ "\u7f51\u7edc\u5f02\u5e38",
+ "\u91cd\u65b0\u4e0a\u4f20",
+ "\u4e0a\u4f20\u4e2d\u65ad",
+ ]
+ in_progress_texts = [
+ "\u53d6\u6d88\u4e0a\u4f20",
+ "\u4e0a\u4f20\u4e2d",
+ "\u6b63\u5728\u4e0a\u4f20",
+ "\u751f\u6210\u4e2d",
+ "\u6587\u4ef6\u4e0a\u4f20\u4e2d",
+ "\u8bf7\u7a0d\u540e\u70b9\u51fb\u53d1\u5e03",
+ ]
+
+ start_time = time.time()
+ while time.time() - start_time < self.UPLOAD_TIMEOUT:
+ for text in failure_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ await self._save_debug_screenshot(page, "upload_failed")
+ return False, f"上传失败:{text}"
+
+ for text in success_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ return True, f"上传完成:{text}"
+
+ delete_visible = await self._is_text_visible(page, "删除", exact=False)
+ cancel_visible = await self._is_text_visible(page, "取消上传", exact=False)
+ upload_in_progress = cancel_visible
+ if not upload_in_progress:
+ for text in in_progress_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ upload_in_progress = True
+ break
+
+ if upload_in_progress:
+ logger.info("[weixin] upload still in progress...")
+ if int(time.time() - start_time) % 20 == 0:
+ await self._save_debug_screenshot(page, "upload_waiting")
+ await asyncio.sleep(self.POLL_INTERVAL)
+ continue
+
+ if delete_visible and not cancel_visible:
+ return True, "上传完成:可删除视频"
+
+ logger.info("[weixin] waiting for upload...")
+ if int(time.time() - start_time) % 20 == 0:
+ await self._save_debug_screenshot(page, "upload_waiting")
+ await asyncio.sleep(self.POLL_INTERVAL)
+
+ return False, "上传超时"
+
+ def _normalize_tags(self, tags: List[str]) -> List[str]:
+ normalized: List[str] = []
+ for tag in tags:
+ value = (tag or "").strip().lstrip("#")
+ if not value:
+ continue
+ if value not in normalized:
+ normalized.append(value)
+ return normalized
+
+ async def _fill_text_field(self, page, locator, text: str) -> bool:
+ if not text:
+ return False
+
+ try:
+ await locator.click(timeout=2000)
+ except Exception:
+ return False
+
+ try:
+ tag_name = await locator.evaluate("el => (el.tagName || '').toLowerCase()")
+ except Exception:
+ tag_name = ""
+
+ try:
+ is_contenteditable = (await locator.get_attribute("contenteditable") or "").lower() == "true"
+ except Exception:
+ is_contenteditable = False
+
+ try:
+ if is_contenteditable or tag_name == "div":
+ await page.keyboard.press("Control+KeyA")
+ await page.keyboard.press("Backspace")
+ await page.keyboard.type(text)
+ current = await locator.evaluate("el => (el.innerText || el.textContent || '').trim()")
+ else:
+ await locator.fill(text)
+ try:
+ current = await locator.input_value()
+ except Exception:
+ current = await locator.evaluate("el => (el.value || '').trim()")
+
+ expected = text[: min(8, len(text))]
+ return expected in (current or "")
+ except Exception:
+ return False
+
+ async def _fill_title(self, page, title: str) -> bool:
+ if not title:
+ return False
+
+ title_text = title[:30]
+ selectors = [
+ "input[placeholder*='视频标题']",
+ "textarea[placeholder*='视频标题']",
+ "input[placeholder*='标题(必填)']",
+ "textarea[placeholder*='标题(必填)']",
+ "input[placeholder*='\\u6807\\u9898']",
+ "textarea[placeholder*='\\u6807\\u9898']",
+ "input[placeholder*='\\u4f5c\\u54c1']",
+ "textarea[placeholder*='\\u4f5c\\u54c1']",
+ "input[placeholder*='\\u6982\\u62ec']",
+ "textarea[placeholder*='\\u6982\\u62ec']",
+ "input[placeholder*='\\u77ed\\u6807\\u9898']",
+ "textarea[placeholder*='\\u77ed\\u6807\\u9898']",
+ ]
+
+ for selector in selectors:
+ try:
+ locator = page.locator(selector).first
+ if await locator.count() > 0:
+ if await self._fill_text_field(page, locator, title_text):
+ logger.info("[weixin] 标题填写成功")
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ async def _fill_description(self, page, description: str) -> bool:
+ if not description:
+ return False
+
+ label_texts = ["视频描述", "描述(必填)", "描述"]
+ for label_text in label_texts:
+ try:
+ label = page.get_by_text(label_text, exact=False).first
+ if await label.count() == 0:
+ continue
+ relation_selectors = [
+ "xpath=following::textarea[1]",
+ "xpath=following::*[@contenteditable='true'][1]",
+ "xpath=following::div[contains(@class,'editor')][1]",
+ ]
+ for relation in relation_selectors:
+ try:
+ target = label.locator(relation).first
+ if await target.count() > 0 and await self._fill_text_field(page, target, description):
+ logger.info("[weixin] 视频描述填写成功(标签定位)")
+ return True
+ except Exception:
+ continue
+ except Exception:
+ continue
+
+ selectors = [
+ "textarea[placeholder*='视频描述']",
+ "textarea[placeholder*='描述(必填)']",
+ "div[contenteditable='true'][data-placeholder*='视频描述']",
+ "div[contenteditable='true'][data-placeholder*='描述']",
+ "div[contenteditable='true'][placeholder*='视频描述']",
+ "div[contenteditable='true'][placeholder*='描述']",
+ "textarea[placeholder*='\\u6dfb\\u52a0\\u63cf\\u8ff0']",
+ "textarea[placeholder*='\\u63cf\\u8ff0']",
+ "textarea[placeholder*='\\u7b80\\u4ecb']",
+ "textarea[placeholder*='\\u6587\\u6848']",
+ "textarea[placeholder*='\\u5185\\u5bb9']",
+ ]
+
+ for selector in selectors:
+ try:
+ locator = page.locator(selector).first
+ if await locator.count() > 0:
+ if await self._fill_text_field(page, locator, description):
+ logger.info("[weixin] 视频描述填写成功")
+ return True
+ except Exception:
+ continue
+
+ # 最后兜底,避免页面改版导致无法输入
+ fallback_editables = page.locator("div[contenteditable='true']")
+ try:
+ count = await fallback_editables.count()
+ except Exception:
+ count = 0
+ for idx in range(min(count, 5)):
+ try:
+ target = fallback_editables.nth(idx)
+ if await self._fill_text_field(page, target, description):
+ logger.info("[weixin] 视频描述填写成功(兜底contenteditable)")
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ async def _fill_tags(self, page, tags: List[str]) -> bool:
+ normalized_tags = self._normalize_tags(tags)
+ if not normalized_tags:
+ return False
+
+ selectors = [
+ "input[placeholder*='\\u8bdd\\u9898']",
+ "input[placeholder*='\\u6807\\u7b7e']",
+ "input[placeholder*='\\u6dfb\\u52a0']",
+ "textarea[placeholder*='\\u8bdd\\u9898']",
+ ]
+
+ for selector in selectors:
+ try:
+ locator = page.locator(selector).first
+ if await locator.count() == 0:
+ continue
+ for tag in normalized_tags:
+ await locator.type(f"#{tag} ")
+ await page.keyboard.press("Enter")
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ async def set_schedule_time(self, page, publish_date: datetime) -> bool:
+ selectors = [
+ "label:has-text('\\u5b9a\\u65f6\\u53d1\\u5e03')",
+ "button:has-text('\\u5b9a\\u65f6\\u53d1\\u5e03')",
+ "label:has-text('\\u9884\\u7ea6\\u53d1\\u5e03')",
+ "button:has-text('\\u9884\\u7ea6\\u53d1\\u5e03')",
+ ]
+
+ for selector in selectors:
+ try:
+ button = await self._first_visible_locator(page.locator(selector))
+ if button:
+ await button.click()
+ await asyncio.sleep(0.5)
+ break
+ except Exception:
+ continue
+
+ time_value = publish_date.strftime("%Y-%m-%d %H:%M")
+ input_selectors = [
+ "input[placeholder*='\\u65e5\\u671f']",
+ "input[placeholder*='\\u65f6\\u95f4']",
+ "input[placeholder*='\\u9009\\u62e9']",
+ ]
+
+ for selector in input_selectors:
+ try:
+ locator = page.locator(selector).first
+ if await locator.count() > 0:
+ await locator.click()
+ await page.keyboard.press("Control+KeyA")
+ await page.keyboard.type(time_value)
+ await page.keyboard.press("Enter")
+ logger.info(f"[weixin] scheduled publish set: {time_value}")
+ return True
+ except Exception:
+ continue
+
+ logger.warning("[weixin] schedule publish input not found")
+ return False
+
+ async def _click_publish(self, page, scheduled: bool) -> bool:
+ selectors = [
+ "button:has-text('\\u5b9a\\u65f6\\u53d1\\u8868')",
+ "button:has-text('\\u5b9a\\u65f6\\u53d1\\u5e03')",
+ "button:has-text('\\u53d1\\u8868')",
+ "button:has-text('\\u53d1\\u5e03')",
+ "button:has-text('\\u786e\\u8ba4\\u53d1\\u8868')",
+ "button:has-text('\\u786e\\u8ba4')",
+ "div[role='button']:has-text('\\u53d1\\u8868')",
+ "a:has-text('\\u53d1\\u8868')",
+ ]
+
+ if not scheduled:
+ selectors = [
+ "button:has-text('\\u53d1\\u8868')",
+ "button:has-text('\\u53d1\\u5e03')",
+ "button:has-text('\\u786e\\u8ba4\\u53d1\\u8868')",
+ "button:has-text('\\u786e\\u8ba4')",
+ "div[role='button']:has-text('\\u53d1\\u8868')",
+ "a:has-text('\\u53d1\\u8868')",
+ ]
+
+ start_time = time.monotonic()
+ found_disabled = False
+ while time.monotonic() - start_time < 90:
+ scopes = self._iter_scopes(page)
+ for scope in scopes:
+ await self._scroll_to_bottom(scope)
+ for selector in selectors:
+ try:
+ locator = scope.locator(selector)
+ count = await locator.count()
+ for i in range(count):
+ candidate = locator.nth(i)
+ try:
+ await candidate.scroll_into_view_if_needed()
+ except Exception:
+ pass
+ if await candidate.is_visible():
+ if await candidate.is_enabled():
+ await candidate.click()
+ await asyncio.sleep(1)
+ return True
+ found_disabled = True
+ except Exception:
+ continue
+ if await self._click_publish_via_dom(scope):
+ await asyncio.sleep(1)
+ return True
+ for text in ("发表", "发布"):
+ try:
+ locator = scope.locator("button, [role='button'], a", has_text=text)
+ if await locator.count() > 0:
+ await locator.first.click(force=True)
+ await asyncio.sleep(1)
+ return True
+ except Exception:
+ continue
+ await self._scroll_down(page, steps=1)
+ await asyncio.sleep(1)
+
+ if found_disabled:
+ logger.warning("[weixin] publish button disabled, likely missing required fields or processing")
+ await self._save_debug_screenshot(page, "publish_button_not_found")
+
+ return False
+
+ async def _wait_for_publish_result(self, page):
+ success_texts = [
+ "\u53d1\u5e03\u6210\u529f",
+ "\u53d1\u5e03\u5b8c\u6210",
+ "\u5df2\u53d1\u5e03",
+ "\u5ba1\u6838\u4e2d",
+ "\u5f85\u5ba1\u6838",
+ "\u63d0\u4ea4\u6210\u529f",
+ "\u5df2\u63d0\u4ea4",
+ ]
+ failure_texts = [
+ "\u53d1\u5e03\u5931\u8d25",
+ "\u53d1\u5e03\u5f02\u5e38",
+ "\u53d1\u5e03\u51fa\u9519",
+ "\u8bf7\u5b8c\u5584",
+ "\u8bf7\u8865\u5145",
+ "\u64cd\u4f5c\u5931\u8d25",
+ "\u7f51\u7edc\u5f02\u5e38",
+ ]
+
+ start_time = time.time()
+ last_capture = -1
+ while time.time() - start_time < self.PUBLISH_TIMEOUT:
+ current_url = page.url
+
+ if self._publish_api_error:
+ return False, self._publish_api_error, False
+
+ if self._post_create_submitted and (
+ "/post/list" in current_url
+ or "/platform/post/list" in current_url
+ ):
+ return True, "发布成功:已进入内容列表", False
+
+ if "channels.weixin.qq.com/platform" in current_url:
+ for text in success_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ return True, f"发布成功:{text}", False
+
+ for text in failure_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ return False, f"发布失败:{text}", False
+
+ for text in success_texts:
+ if await self._is_text_visible(page, text, exact=False):
+ return True, f"发布成功:{text}", False
+
+ logger.info("[weixin] waiting for publish result...")
+ elapsed = int(time.time() - start_time)
+ if elapsed % 20 == 0 and elapsed != last_capture:
+ last_capture = elapsed
+ await self._save_debug_screenshot(page, "publish_waiting")
+ await asyncio.sleep(self.POLL_INTERVAL)
+
+ return False, "发布超时", True
+
+ async def upload(self, playwright: Playwright) -> Dict[str, Any]:
+ browser = None
+ context = None
+ page = None
+ recorded_video = None
+ final_success = False
+ try:
+ launch_options = self._build_launch_options()
+ browser = await playwright.chromium.launch(**launch_options)
+ context_kwargs: Dict[str, Any] = {
+ "storage_state": self.account_file,
+ "viewport": {"width": 1440, "height": 1000},
+ "device_scale_factor": 1,
+ "user_agent": settings.WEIXIN_USER_AGENT,
+ "locale": settings.WEIXIN_LOCALE,
+ "timezone_id": settings.WEIXIN_TIMEZONE_ID,
+ }
+ if self._record_video_enabled():
+ context_kwargs["record_video_dir"] = str(self._video_record_dir())
+ context_kwargs["record_video_size"] = {
+ "width": settings.WEIXIN_RECORD_VIDEO_WIDTH,
+ "height": settings.WEIXIN_RECORD_VIDEO_HEIGHT,
+ }
+ context = await browser.new_context(**context_kwargs)
+ context = await set_init_script(context)
+
+ page = await context.new_page()
+ recorded_video = page.video
+ self._attach_debug_listeners(page)
+ await page.goto(self.upload_url, wait_until="domcontentloaded")
+ await asyncio.sleep(2)
+ await self._apply_page_zoom(page)
+
+ if await self._is_login_page(page):
+ return {
+ "success": False,
+ "message": "登录失效,请重新扫码登录微信视频号",
+ "url": None,
+ }
+
+ if not await self._upload_video(page):
+ return {
+ "success": False,
+ "message": "未找到上传入口,请确认已进入发表视频页面",
+ "url": None,
+ }
+
+ upload_success, upload_reason = await self._wait_for_upload_complete(page)
+ if not upload_success:
+ return {
+ "success": False,
+ "message": upload_reason,
+ "url": None,
+ }
+
+ await asyncio.sleep(1)
+
+ # 按新规则:标题和标签统一写入“视频描述”
+ normalized_tags = self._normalize_tags(self.tags)
+ description_parts: List[str] = []
+ if self.title:
+ description_parts.append(self.title.strip())
+ if self.description:
+ description_parts.append(self.description.strip())
+ if normalized_tags:
+ description_parts.append(" ".join([f"#{tag}" for tag in normalized_tags]))
+ description_text = "\n".join([part for part in description_parts if part]).strip()
+
+ if description_text:
+ description_filled = await self._fill_description(page, description_text)
+ if not description_filled:
+ logger.error("[weixin] 未找到视频描述输入框,无法写入标题和标签")
+ return {
+ "success": False,
+ "message": "未找到视频描述输入框,无法填写标题和标签",
+ "url": None,
+ }
+
+ publish_date = self.publish_date
+ if publish_date != 0 and isinstance(publish_date, datetime):
+ await self.set_schedule_time(page, publish_date)
+
+ self._post_create_submitted = False
+ self._publish_api_error = None
+ if not await self._click_publish(page, scheduled=self.publish_date != 0):
+ return {
+ "success": False,
+ "message": "未定位到可点击的发表按钮,可能在页面底部或被遮挡",
+ "url": None,
+ }
+
+ publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(page)
+ await context.storage_state(path=self.account_file)
+
+ if publish_success:
+ final_success = True
+ try:
+ await page.wait_for_load_state("domcontentloaded", timeout=5000)
+ except Exception:
+ pass
+ await asyncio.sleep(3)
+ screenshot_url = await self._save_publish_success_screenshot(page)
+ return {
+ "success": True,
+ "message": "发布成功",
+ "url": None,
+ "screenshot_url": screenshot_url,
+ }
+
+ if is_timeout:
+ timeout_message = "发布超时,请到视频号助手确认发布结果"
+ if self._post_create_submitted:
+ timeout_message = "发布请求已提交,但未收到成功确认,请到视频号助手核验"
+ return {
+ "success": False,
+ "message": timeout_message,
+ "url": None,
+ }
+
+ return {
+ "success": False,
+ "message": publish_reason,
+ "url": None,
+ }
+
+ except Exception as e:
+ logger.exception(f"[weixin] upload failed: {e}")
+ return {
+ "success": False,
+ "message": f"上传失败:{str(e)}",
+ "url": None,
+ }
+ finally:
+ video_path = None
+ if page:
+ try:
+ if not page.is_closed():
+ await page.close()
+ except Exception:
+ pass
+ video_path = await self._save_recorded_video(recorded_video, final_success)
+ if video_path:
+ logger.info(f"[weixin] 调试录屏已保存: {video_path}")
+
+ if context:
+ try:
+ await context.close()
+ except Exception:
+ pass
+ if browser:
+ try:
+ await browser.close()
+ except Exception:
+ pass
+ self._cleanup_upload_file()
+
+ async def main(self) -> Dict[str, Any]:
+ async with async_playwright() as playwright:
+ return await self.upload(playwright)
diff --git a/frontend/src/features/publish/model/usePublishController.ts b/frontend/src/features/publish/model/usePublishController.ts
index 7b1589a..8196559 100644
--- a/frontend/src/features/publish/model/usePublishController.ts
+++ b/frontend/src/features/publish/model/usePublishController.ts
@@ -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>(url).then((res) => unwrap(res.data));
@@ -45,9 +53,7 @@ export const usePublishController = () => {
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [isPublishing, setIsPublishing] = useState(false);
- const [publishResults, setPublishResults] = useState([]);
- const [scheduleMode, setScheduleMode] = useState<"now" | "scheduled">("now");
- const [publishTime, setPublishTime] = useState("");
+ const [publishResults, setPublishResults] = useState([]);
const [qrCodeImage, setQrCodeImage] = useState(null);
const [qrPlatform, setQrPlatform] = useState(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,
diff --git a/frontend/src/features/publish/ui/PublishPage.tsx b/frontend/src/features/publish/ui/PublishPage.tsx
index b184146..bcb6a83 100644
--- a/frontend/src/features/publish/ui/PublishPage.tsx
+++ b/frontend/src/features/publish/ui/PublishPage.tsx
@@ -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() {
- {/* 定时发布 */}
-
-
- ⏰ 发布设置
-
-
-
-
-
-
-
-
- {scheduleMode === "scheduled" && (
-
setPublishTime(e.target.value)}
- className="w-full p-3 bg-black/30 border border-white/10 rounded-xl text-white"
- />
- )}
-
-
-
{/* 发布按钮 */}
{/* 发布结果 */}
@@ -432,6 +381,23 @@ export function PublishPage() {
{result.message}
+ {result.success && result.screenshot_url && (
+
+
发布成功截图
+
+
+
+
+ )}
))}
diff --git a/run_backend.sh b/run_backend.sh
index 53d70f9..eff715e 100644
--- a/run_backend.sh
+++ b/run_backend.sh
@@ -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"