1430 lines
55 KiB
Python
1430 lines
55 KiB
Python
"""
|
||
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)
|