From 945262a7fc49b225da594005c7aa2860a8068ace Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Fri, 6 Feb 2026 16:02:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 15 +- Docs/BACKEND_README.md | 4 +- Docs/DEPLOY_MANUAL.md | 90 +- Docs/DevLogs/Day18.md | 59 + Docs/DevLogs/Day19.md | 485 ++++++ Docs/FRONTEND_DEV.md | 7 + Docs/FRONTEND_README.md | 2 +- Docs/task_complete.md | 29 +- README.md | 6 +- backend/app/core/config.py | 38 +- backend/app/modules/login_helper/router.py | 22 +- backend/app/modules/publish/router.py | 36 +- backend/app/services/publish_service.py | 54 +- backend/app/services/qr_login_service.py | 315 +++- backend/app/services/uploader/__init__.py | 5 +- .../app/services/uploader/douyin_uploader.py | 1456 ++++++++++++++--- .../app/services/uploader/weixin_uploader.py | 1426 ++++++++++++++++ .../publish/model/usePublishController.ts | 52 +- .../src/features/publish/ui/PublishPage.tsx | 72 +- run_backend.sh | 18 +- 20 files changed, 3709 insertions(+), 482 deletions(-) create mode 100644 Docs/DevLogs/Day19.md create mode 100644 backend/app/services/uploader/weixin_uploader.py 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"