Files
ViGent2/backend/app/services/uploader/weixin_uploader.py
Kevin Wong ee342cc40f 更新
2026-02-08 16:23:39 +08:00

1430 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
# post_create 响应监听始终注册(不依赖 debug 开关)
def log_post_create(response):
try:
url = response.url or ""
if "/post/post_create" in url:
if response.status < 400:
self._post_create_submitted = True
logger.info("[weixin][publish] post_create API ok")
else:
self._publish_api_error = f"发布请求失败HTTP {response.status}"
logger.warning(f"[weixin][publish] post_create_failed status={response.status}")
except Exception:
pass
page.on("response", log_post_create)
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):
"""点击发表后等待结果:页面离开创建页即视为成功"""
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",
]
# 记录点击发表时的 URL用于判断是否跳转
create_url = page.url
start_time = time.time()
while time.time() - start_time < self.PUBLISH_TIMEOUT:
current_url = page.url
# API 层面报错 → 直接失败
if self._publish_api_error:
return False, self._publish_api_error, False
# 核心判定URL 离开了创建页(跳转到列表页或其他页面)→ 发布成功
if current_url != create_url and "/post/create" not in current_url:
logger.info(f"[weixin] page navigated away from create page: {current_url}")
return True, "发布成功:页面已跳转", False
# post_create API 已确认成功 → 也视为成功
if self._post_create_submitted:
logger.info("[weixin] post_create API confirmed success")
return True, "发布成功API 已确认", False
# 检查页面上的失败文案
for text in failure_texts:
if await self._is_text_visible(page, text, exact=False):
return False, f"发布失败:{text}", False
logger.info("[weixin] waiting for publish result...")
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)