| 1 | # -*- coding: utf-8 -*- |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import asyncio |
| 5 | import inspect |
| 6 | import json as _json |
| 7 | import os |
| 8 | import re |
| 9 | import time |
| 10 | from pathlib import Path |
| 11 | |
| 12 | from playwright.async_api import Page, Playwright, TimeoutError as PWTimeoutError, async_playwright |
| 13 | |
| 14 | from conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH |
| 15 | from uploader.base_video import BaseVideoUploader |
| 16 | from utils.base_social_media import set_init_script |
| 17 | from utils.log import alipay_logger |
| 18 | from utils.login_qrcode import build_login_qrcode_path |
| 19 | from utils.login_qrcode import decode_qrcode_from_path |
| 20 | from utils.login_qrcode import print_terminal_qrcode |
| 21 | from utils.login_qrcode import remove_qrcode_file |
| 22 | |
| 23 | ALIPAY_HOME_URL = "https://c.alipay.com/" |
| 24 | ALIPAY_PORTAL_HOME = "https://c.alipay.com/page/portal/home" |
| 25 | # 内容创作平台入口:必须带 _appScene=CONTENT&appId=xxx,否则会跳到生活号开通页 signup |
| 26 | ALIPAY_LIFE_ACCOUNT_URL = "https://c.alipay.com/page/life-account/index?_appScene=CONTENT&appId=2030022469359777" |
| 27 | ALIPAY_POSTS_URL = "https://c.alipay.com/page/content-creation/posts" |
| 28 | |
| 29 | |
| 30 | def _msg(emoji: str, text: str) -> str: |
| 31 | return f"{emoji} {text}" |
| 32 | |
| 33 | |
| 34 | def _build_login_result(success: bool, status: str, message: str, account_file: str, qrcode: dict | None = None, current_url: str = "") -> dict: |
| 35 | return { |
| 36 | "success": success, |
| 37 | "status": status, |
| 38 | "message": message, |
| 39 | "account_file": str(account_file), |
| 40 | "qrcode": qrcode, |
| 41 | "current_url": current_url, |
| 42 | } |
| 43 | |
| 44 | |
| 45 | async def _emit_qrcode_callback(qrcode_callback, payload: dict): |
| 46 | if not qrcode_callback: |
| 47 | return |
| 48 | callback_result = qrcode_callback(payload) |
| 49 | if inspect.isawaitable(callback_result): |
| 50 | await callback_result |
| 51 | |
| 52 | |
| 53 | def _build_launch_kwargs(headless: bool) -> dict: |
| 54 | launch_kwargs = {"headless": headless} |
| 55 | if LOCAL_CHROME_PATH: |
| 56 | launch_kwargs["executable_path"] = LOCAL_CHROME_PATH |
| 57 | return launch_kwargs |
| 58 | |
| 59 | |
| 60 | def _resolve_account_file(account_file: str | Path) -> str: |
| 61 | path = Path(account_file).expanduser() |
| 62 | if path.is_absolute(): |
| 63 | return str(path) |
| 64 | |
| 65 | if len(path.parts) == 1: |
| 66 | return str((Path(BASE_DIR) / "cookies" / "alipay_uploader" / path).resolve()) |
| 67 | |
| 68 | return str(path.resolve()) |
| 69 | |
| 70 | |
| 71 | def format_title_with_tags(title: str, tags: list[str], max_length: int = 30) -> str: |
| 72 | """支付宝生活号的标签是 #tag 格式,拼在标题末尾一起填入标题输入框。 |
| 73 | |
| 74 | 按标签边界截断:先保证标题完整,再逐个追加标签,加不下的**整个标签丢弃**, |
| 75 | 绝不留半个标签或裸 `#`。这避免触发支付宝"断字"优化项弹窗阻断发布。 |
| 76 | """ |
| 77 | if not tags: |
| 78 | return title[:max_length] |
| 79 | result = title |
| 80 | for tag in tags: |
| 81 | candidate = result + " #" + tag.strip("#") |
| 82 | if len(candidate) > max_length: |
| 83 | break |
| 84 | result = candidate |
| 85 | return result |
| 86 | |
| 87 | |
| 88 | async def _capture_alipay_qr(page: Page, account_file: str, previous_qrcode_path: Path | None = None) -> dict: |
| 89 | """严格按支付宝登录页固定 DOM 截二维码(只认扫码 tab + barcode canvas)。""" |
| 90 | login_iframe = page.locator('iframe[title="login"]') |
| 91 | await login_iframe.first.wait_for(state="attached", timeout=30000) |
| 92 | frame = page.frame_locator('iframe[title="login"]') |
| 93 | |
| 94 | try: |
| 95 | await frame.locator("#J-loginMethod-tabs").first.wait_for(state="visible", timeout=15000) |
| 96 | # 固定切到扫码 tab(若本来已是扫码,重复点击无副作用) |
| 97 | await frame.locator("#J-loginMethod-tabs li[data-status='show_qr']").first.click(timeout=5000) |
| 98 | except PWTimeoutError: |
| 99 | # 兼容另一种渲染:直接落在扫码区(无 tabs) |
| 100 | pass |
| 101 | |
| 102 | # 只认二维码容器,不认账密元素 |
| 103 | await frame.locator("#J-qrcode, #J-barcode-container").first.wait_for(state="visible", timeout=25000) |
| 104 | await frame.locator("#J-barcode-container canvas.barcode, #J-barcode-container canvas").first.wait_for(state="visible", timeout=25000) |
| 105 | |
| 106 | # 账密区域在扫码模式下应隐藏 |
| 107 | try: |
| 108 | login_panel = frame.locator("#J-login") |
| 109 | if await login_panel.count(): |
| 110 | klass = (await login_panel.first.get_attribute("class") or "") |
| 111 | if "fn-hide" not in klass: |
| 112 | raise RuntimeError("当前仍处于账密登录面板(#J-login 未隐藏)") |
| 113 | except RuntimeError: |
| 114 | raise |
| 115 | except Exception: |
| 116 | pass |
| 117 | |
| 118 | qrcode_path = build_login_qrcode_path(account_file) |
| 119 | qrcode_path.parent.mkdir(parents=True, exist_ok=True) |
| 120 | |
| 121 | qr_canvas = frame.locator("#J-barcode-container canvas.barcode, #J-barcode-container canvas").first |
| 122 | await qr_canvas.screenshot(path=str(qrcode_path), timeout=15000) |
| 123 | |
| 124 | qrcode_content = decode_qrcode_from_path(qrcode_path) |
| 125 | if previous_qrcode_path and previous_qrcode_path != qrcode_path: |
| 126 | if remove_qrcode_file(previous_qrcode_path): |
| 127 | alipay_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}")) |
| 128 | alipay_logger.info(_msg("🖼️", f"二维码已保存到: {qrcode_path}")) |
| 129 | if qrcode_content: |
| 130 | print_terminal_qrcode(qrcode_content, qrcode_path, "支付宝APP") |
| 131 | else: |
| 132 | alipay_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码")) |
| 133 | return {"image_path": str(qrcode_path), "image_data_url": ""} |
| 134 | |
| 135 | |
| 136 | async def alipay_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 100, headless: bool = LOCAL_CHROME_HEADLESS): |
| 137 | """打开浏览器,用户扫码登录支付宝生活号,登录成功后保存 cookie(镜像 douyin_cookie_gen)。 |
| 138 | |
| 139 | 二维码 png 落 cookies 目录(*login_qrcode*.png)供终端显示/告知位置;qrcode_callback 可选(如 relogin 推飞书)。 |
| 140 | headless=False 时也可直接在弹出的浏览器里扫码。 |
| 141 | 返回 _build_login_result 结果 dict。 |
| 142 | """ |
| 143 | account_file = _resolve_account_file(account_file) |
| 144 | Path(account_file).parent.mkdir(parents=True, exist_ok=True) |
| 145 | qrcode_path = None |
| 146 | result = _build_login_result(False, "failed", "支付宝登录失败", account_file) |
| 147 | async with async_playwright() as playwright: |
| 148 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless)) |
| 149 | context = await browser.new_context() |
| 150 | try: |
| 151 | page = await context.new_page() |
| 152 | # 注意:不能用 set_init_script(stealth) —— 实验证明 stealth 会阻止支付宝登录 iframe 注入 |
| 153 | # 先进受保护的后台页,未登录会触发登录弹窗(iframe[title="login"],异步注入约 2~4s) |
| 154 | await page.goto(ALIPAY_PORTAL_HOME, timeout=60000, wait_until="domcontentloaded") |
| 155 | if headless: |
| 156 | alipay_logger.info(_msg("🧍", "无头登录中:二维码已存为图片,请在终端扫或打开图片扫码(或由发布进程推送到飞书)")) |
| 157 | else: |
| 158 | alipay_logger.info(_msg("🧍", "请在打开的浏览器中扫码登录支付宝生活号(登录完成后请勿手动关闭浏览器)")) |
| 159 | |
| 160 | # 等登录 iframe 出现 |
| 161 | login_iframe = page.locator('iframe[title="login"]') |
| 162 | for _ in range(30): |
| 163 | try: |
| 164 | if await login_iframe.count(): |
| 165 | break |
| 166 | except Exception: |
| 167 | pass |
| 168 | await page.wait_for_timeout(1000) |
| 169 | else: |
| 170 | await context.close() |
| 171 | await browser.close() |
| 172 | return _build_login_result(False, "timeout", "等待登录弹窗超时(30s),放弃保存", account_file, current_url=page.url) |
| 173 | |
| 174 | # 截图二维码(无头时给终端/飞书;有头时也截一份备用,不致命) |
| 175 | qrcode_info = await _capture_alipay_qr(page, account_file) |
| 176 | qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None |
| 177 | await _emit_qrcode_callback(qrcode_callback, qrcode_info) |
| 178 | alipay_logger.info(_msg("🧍", "请扫码,正在耐心等待登录完成")) |
| 179 | |
| 180 | # 轮询等待登录完成:登录 iframe 消失(登录成功后 auth 页会跳走) |
| 181 | for _i in range(max_checks): # 最多等 3~5 分钟 |
| 182 | if await _is_alipay_login_completed(page, login_iframe): |
| 183 | alipay_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}")) |
| 184 | result = _build_login_result(True, "success", "支付宝扫码登录成功", account_file, qrcode_info, page.url) |
| 185 | break |
| 186 | await page.wait_for_timeout(poll_interval * 1000) |
| 187 | else: |
| 188 | result = _build_login_result(False, "timeout", "等待支付宝扫码登录超时", account_file, qrcode_info, page.url) |
| 189 | |
| 190 | if result["success"]: |
| 191 | await asyncio.sleep(2) |
| 192 | await context.storage_state(path=account_file) |
| 193 | # 登录结束,轻量确认 cookie 文件里有点东西 |
| 194 | try: |
| 195 | _d = _json.load(open(account_file)) |
| 196 | _has_cookie = any(c.get("value") for c in _d.get("cookies", [])) |
| 197 | if not _has_cookie: |
| 198 | result = _build_login_result(False, "cookie_invalid", "支付宝扫码流程结束,但 cookie 为空", account_file, qrcode_info, page.url) |
| 199 | except Exception as _e: |
| 200 | alipay_logger.warning(_msg("⚠️", f"cookie 文件校验异常(忽略,按成功处理): {_e}")) |
| 201 | except Exception as exc: |
| 202 | result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "") |
| 203 | finally: |
| 204 | if remove_qrcode_file(qrcode_path): |
| 205 | alipay_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}")) |
| 206 | if not result["success"]: |
| 207 | alipay_logger.error(_msg("😢", f"登录失败: {result['message']}")) |
| 208 | await context.close() |
| 209 | await browser.close() |
| 210 | return result |
| 211 | |
| 212 | |
| 213 | async def _is_alipay_login_completed(page: Page, login_iframe) -> bool: |
| 214 | # 登录成功判定:登录 iframe 消失(登录成功后 auth 页会跳走),且 URL 回到 c.alipay.com 后台不算登录页 |
| 215 | try: |
| 216 | if await login_iframe.count() != 0: |
| 217 | return False |
| 218 | url = page.url |
| 219 | if url.startswith("https://c.alipay.com/") and "login" not in url.lower(): |
| 220 | return True |
| 221 | except Exception: |
| 222 | return False |
| 223 | return False |
| 224 | |
| 225 | |
| 226 | async def cookie_auth(account_file): |
| 227 | account_file = _resolve_account_file(account_file) |
| 228 | async with async_playwright() as playwright: |
| 229 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True)) |
| 230 | try: |
| 231 | context = await browser.new_context(storage_state=account_file) |
| 232 | page = await context.new_page() |
| 233 | await page.goto(ALIPAY_LIFE_ACCOUNT_URL, timeout=60000, wait_until="domcontentloaded") |
| 234 | await page.wait_for_timeout(5000) |
| 235 | |
| 236 | # 登录会整页跳 auth.alipay.com;仍停留在 c.alipay.com 且不在 login 页才算有效 |
| 237 | url = page.url |
| 238 | if url.startswith("https://auth.alipay.com/") or "login" in url.lower(): |
| 239 | alipay_logger.info(_msg("🥹", "cookie 已失效(跳转到登录页)")) |
| 240 | return False |
| 241 | if await page.locator('iframe[title="login"]').count(): |
| 242 | alipay_logger.info(_msg("🥹", "cookie 已失效(出现登录弹窗)")) |
| 243 | return False |
| 244 | |
| 245 | alipay_logger.success(_msg("🥳", "cookie 有效")) |
| 246 | return True |
| 247 | except Exception as exc: |
| 248 | alipay_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}")) |
| 249 | return False |
| 250 | finally: |
| 251 | await browser.close() |
| 252 | |
| 253 | |
| 254 | async def alipay_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS): |
| 255 | account_file = _resolve_account_file(account_file) |
| 256 | if not os.path.exists(account_file) or not await cookie_auth(account_file): |
| 257 | if not handle: |
| 258 | result = _build_login_result(False, "cookie_invalid", "cookie 文件不存在或已失效", account_file) |
| 259 | return result if return_detail else False |
| 260 | alipay_logger.info(_msg("🥹", "cookie 文件不存在或已失效,自动打开浏览器请扫码登录")) |
| 261 | result = await alipay_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless) |
| 262 | return result if return_detail else result["success"] |
| 263 | |
| 264 | result = _build_login_result(True, "cookie_valid", "cookie 有效", account_file) |
| 265 | return result if return_detail else True |
| 266 | |
| 267 | |
| 268 | class AlipayVideo(BaseVideoUploader): |
| 269 | def __init__( |
| 270 | self, |
| 271 | title, |
| 272 | file_path, |
| 273 | tags, |
| 274 | account_file, |
| 275 | desc: str | None = None, |
| 276 | thumbnail_path: str | None = None, |
| 277 | collection_name: str | None = None, |
| 278 | debug: bool = True, |
| 279 | headless: bool = LOCAL_CHROME_HEADLESS, |
| 280 | ): |
| 281 | self.title = title |
| 282 | self.file_path = file_path |
| 283 | self.tags = tags or [] |
| 284 | self.account_file = _resolve_account_file(account_file) |
| 285 | self.desc = desc or "" |
| 286 | self.thumbnail_path = thumbnail_path |
| 287 | self.collection_name = collection_name |
| 288 | self.debug = debug |
| 289 | self.headless = headless |
| 290 | self.local_executable_path = LOCAL_CHROME_PATH |
| 291 | self.max_title_length = 30 |
| 292 | |
| 293 | async def validate_upload_args(self): |
| 294 | if not os.path.exists(self.account_file): |
| 295 | raise RuntimeError(f"cookie文件不存在,请先完成支付宝生活号登录: {self.account_file}") |
| 296 | if not await cookie_auth(self.account_file): |
| 297 | raise RuntimeError(f"cookie文件已失效,请先完成支付宝生活号登录: {self.account_file}") |
| 298 | if not self.title or not str(self.title).strip(): |
| 299 | raise ValueError("视频模式下,title 是必须的") |
| 300 | self.file_path = str(self.validate_video_file(self.file_path)) |
| 301 | if self.thumbnail_path: |
| 302 | self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path)) |
| 303 | |
| 304 | async def open_upload_page(self, page: Page) -> None: |
| 305 | # 进内容创作平台首页,点"发布视频"卡片(JS 跳转)进入短视频发布表单 |
| 306 | await page.goto(ALIPAY_LIFE_ACCOUNT_URL, timeout=120000, wait_until="domcontentloaded") |
| 307 | try: |
| 308 | await page.wait_for_load_state("networkidle", timeout=15000) |
| 309 | except Exception: |
| 310 | pass |
| 311 | |
| 312 | publish_entry = page.locator('a:has-text("发布视频推荐分辨率720p及以上,建议1080p")').first |
| 313 | try: |
| 314 | await publish_entry.wait_for(state="visible", timeout=30000) |
| 315 | await publish_entry.click() |
| 316 | except Exception as exc: |
| 317 | alipay_logger.warning(_msg("😵", f"点击「发布视频」入口失败: {exc}")) |
| 318 | raise |
| 319 | |
| 320 | await page.wait_for_url("**/content-creation/publish/short-video**", timeout=60000) |
| 321 | |
| 322 | async def upload_video_file(self, page: Page, file_path: str) -> None: |
| 323 | file_input = page.locator('input[type="file"]').first |
| 324 | await file_input.wait_for(state="attached", timeout=30000) |
| 325 | await file_input.set_input_files(file_path) |
| 326 | alipay_logger.info(_msg("🏃", f"已选择视频文件: {file_path}")) |
| 327 | |
| 328 | async def fill_title_and_tags(self, page: Page) -> None: |
| 329 | title_field = page.get_by_placeholder("一个好的标题,能获得更多人的喜欢哦").first |
| 330 | await title_field.wait_for(state="visible", timeout=30000) |
| 331 | value = format_title_with_tags(self.title, self.tags, max_length=self.max_title_length) |
| 332 | await title_field.fill(value) |
| 333 | alipay_logger.info(_msg("🏷️", f"标题已填写(含标签共 {len(value)} 字): {value}")) |
| 334 | |
| 335 | async def fill_description(self, page: Page) -> None: |
| 336 | if not self.desc: |
| 337 | return |
| 338 | desc_field = page.get_by_placeholder("填写作品描述,让你的作品更容易被看到").first |
| 339 | await desc_field.fill(self.desc) |
| 340 | alipay_logger.info(_msg("🏷️", f"作品描述已填写: {self.desc[:40]}")) |
| 341 | |
| 342 | async def upload_thumbnail(self, page: Page) -> None: |
| 343 | if not self.thumbnail_path: |
| 344 | return |
| 345 | try: |
| 346 | # 1) 点发布表单的"上传封面"入口,弹出"截取封面"弹窗 |
| 347 | cover_el = page.get_by_text("上传封面", exact=True).first |
| 348 | await cover_el.scroll_into_view_if_needed() |
| 349 | await cover_el.click(timeout=10000) |
| 350 | await page.wait_for_timeout(2000) |
| 351 | |
| 352 | modal_body = page.locator(".antd5-modal-body").last |
| 353 | |
| 354 | # 2) 弹窗首次是"截取封面/上传封面"两入口;点"上传封面"展开图片上传区 |
| 355 | inner = modal_body.get_by_text("上传封面", exact=True).first |
| 356 | if await inner.count(): |
| 357 | await inner.click(timeout=8000) |
| 358 | await page.wait_for_timeout(1500) |
| 359 | |
| 360 | # 3) 点"上传图片"打开图片选择器 |
| 361 | upload_img_btn = modal_body.get_by_role("button", name="上传图片").first |
| 362 | await upload_img_btn.wait_for(state="visible", timeout=10000) |
| 363 | await upload_img_btn.click() |
| 364 | await page.wait_for_timeout(1500) |
| 365 | |
| 366 | # 4) 设置图片到新出现的图片 file input |
| 367 | img_input = page.locator('input[type="file"][accept*="jpg"], input[type="file"][accept*="png"]').first |
| 368 | await img_input.wait_for(state="attached", timeout=10000) |
| 369 | await img_input.set_input_files(self.thumbnail_path) |
| 370 | await page.wait_for_timeout(3000) |
| 371 | |
| 372 | # 5) 裁剪弹窗里点"完 成" |
| 373 | done_btn = page.get_by_role("button", name="完 成").first |
| 374 | if not await done_btn.count(): |
| 375 | done_btn = page.get_by_role("button", name="完成").first |
| 376 | await done_btn.wait_for(state="visible", timeout=15000) |
| 377 | await done_btn.click() |
| 378 | await page.wait_for_timeout(800) |
| 379 | alipay_logger.success(_msg("🖼️", "封面已上传")) |
| 380 | except Exception as exc: |
| 381 | alipay_logger.warning(_msg("😵", f"封面上传失败,跳过继续: {exc}")) |
| 382 | |
| 383 | async def apply_collection(self, page: Page) -> None: |
| 384 | if not self.collection_name: |
| 385 | return |
| 386 | try: |
| 387 | # antd5-select:点击合集下拉的可点开关(compilation input 的父容器),展开选项 |
| 388 | compilation = page.locator('input[id*="_compilationInfo"]').first |
| 389 | await compilation.scroll_into_view_if_needed() |
| 390 | select = compilation.locator("xpath=../../..").first |
| 391 | if not await select.count(): |
| 392 | select = page.locator( |
| 393 | '.antd5-select:has(.antd5-select-selection-placeholder)' |
| 394 | ).first |
| 395 | await select.click(timeout=8000) |
| 396 | await page.wait_for_timeout(2000) |
| 397 | |
| 398 | # 只在选项里精确找映射合集名;找不到就不归集(用户后续手工建合集后会自动选中) |
| 399 | options = page.locator('[role="option"]') |
| 400 | target = options.filter(has_text=self.collection_name).first |
| 401 | if await target.count(): |
| 402 | try: |
| 403 | await target.click(timeout=5000, force=True) |
| 404 | except Exception: |
| 405 | await target.scroll_into_view_if_needed() |
| 406 | await target.click(timeout=5000) |
| 407 | await page.wait_for_timeout(800) |
| 408 | alipay_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}")) |
| 409 | return |
| 410 | |
| 411 | alipay_logger.warning(_msg("😵", f"账号中无「{self.collection_name}」合集,跳过归集")) |
| 412 | await page.keyboard.press("Escape") |
| 413 | except Exception as exc: |
| 414 | alipay_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}")) |
| 415 | |
| 416 | async def check_ai_label(self, page: Page) -> None: |
| 417 | # 作者声明是 radio 组(默认"内容无需标注"NO_STATEMENT),用 radio.check() 选中"内容由AI生成"(A_AG3) |
| 418 | ai_label = page.locator('label.antd5-radio-wrapper', has_text="内容由AI生成").first |
| 419 | try: |
| 420 | if await ai_label.count(): |
| 421 | radio = ai_label.locator('input[type="radio"]').first |
| 422 | await radio.scroll_into_view_if_needed() |
| 423 | await radio.check(timeout=5000) |
| 424 | await page.wait_for_timeout(500) |
| 425 | alipay_logger.success(_msg("🏷️", "已勾选「内容由AI生成」")) |
| 426 | except Exception as exc: |
| 427 | alipay_logger.warning(_msg("😵", f"勾选「内容由AI生成」失败: {exc}")) |
| 428 | |
| 429 | async def wait_for_upload_complete(self, page: Page, timeout: int = 1800) -> None: |
| 430 | # 等待"确认发布"按钮变为可点击(视频上传+转码完成) |
| 431 | publish_btn = page.get_by_role("button", name="确认发布").first |
| 432 | start = time.monotonic() |
| 433 | while True: |
| 434 | if time.monotonic() - start > timeout: |
| 435 | raise TimeoutError(f"等待视频上传/转码超时(>{timeout}s),确认发布按钮始终不可用") |
| 436 | try: |
| 437 | if not await publish_btn.count(): |
| 438 | await asyncio.sleep(2) |
| 439 | continue |
| 440 | if await publish_btn.is_disabled(): |
| 441 | alipay_logger.info(_msg("🏃", "正在上传/转码视频中...")) |
| 442 | await asyncio.sleep(2) |
| 443 | continue |
| 444 | alipay_logger.success(_msg("🥳", "视频上传完毕")) |
| 445 | return |
| 446 | except Exception: |
| 447 | alipay_logger.info(_msg("🏃", "正在上传/转码视频中...")) |
| 448 | await asyncio.sleep(2) |
| 449 | |
| 450 | async def submit_publish(self, page: Page) -> None: |
| 451 | publish_btn = page.get_by_role("button", name="确认发布").first |
| 452 | await publish_btn.wait_for(state="visible", timeout=30000) |
| 453 | # 某些账号下点击后不会立即跳 posts,需同时观察成功提示文案 |
| 454 | if await publish_btn.is_disabled(): |
| 455 | raise RuntimeError("确认发布按钮仍不可点击,无法提交") |
| 456 | |
| 457 | # 只抓提交发布阶段的关键请求,失败时用于定位根因 |
| 458 | net_events: list[tuple[str, str, int | None, str]] = [] |
| 459 | req_events: list[tuple[str, str, str]] = [] |
| 460 | net_tasks: list[asyncio.Task] = [] |
| 461 | |
| 462 | async def _collect_click_diag(tag: str): |
| 463 | try: |
| 464 | diag = await page.evaluate( |
| 465 | """ |
| 466 | () => { |
| 467 | const btns = Array.from(document.querySelectorAll('button')).filter(b => (b.innerText || '').includes('确认发布')); |
| 468 | const confirmButtons = btns.map((b, i) => { |
| 469 | const r = b.getBoundingClientRect(); |
| 470 | const cx = r.left + r.width / 2; |
| 471 | const cy = r.top + r.height / 2; |
| 472 | const topEl = document.elementFromPoint(cx, cy); |
| 473 | return { |
| 474 | i, |
| 475 | text: (b.innerText || '').trim(), |
| 476 | disabled: !!b.disabled, |
| 477 | ariaDisabled: b.getAttribute('aria-disabled'), |
| 478 | className: b.className, |
| 479 | rect: { x: r.x, y: r.y, w: r.width, h: r.height }, |
| 480 | topElement: topEl ? `${topEl.tagName}.${topEl.className || ''}` : null, |
| 481 | }; |
| 482 | }); |
| 483 | |
| 484 | const visibleModals = Array.from(document.querySelectorAll('.antd5-modal-wrap, .antd5-message, .antd5-notification')).filter(el => { |
| 485 | const st = window.getComputedStyle(el); |
| 486 | const r = el.getBoundingClientRect(); |
| 487 | return st.display !== 'none' && st.visibility !== 'hidden' && r.width > 0 && r.height > 0; |
| 488 | }).slice(0, 5).map(el => ({ |
| 489 | cls: el.className, |
| 490 | text: (el.textContent || '').trim().slice(0, 120), |
| 491 | })); |
| 492 | |
| 493 | const errorHints = Array.from(document.querySelectorAll('.antd5-form-item-explain-error, .ant-form-item-explain-error, [class*="error"]')).map(el => (el.textContent || '').trim()).filter(Boolean).slice(0, 8); |
| 494 | |
| 495 | return { |
| 496 | url: location.href, |
| 497 | title: document.title, |
| 498 | readyState: document.readyState, |
| 499 | confirmButtons, |
| 500 | visibleModals, |
| 501 | errorHints, |
| 502 | activeElement: document.activeElement ? `${document.activeElement.tagName}.${document.activeElement.className || ''}` : null, |
| 503 | }; |
| 504 | } |
| 505 | """ |
| 506 | ) |
| 507 | alipay_logger.info(_msg("🔍", f"{tag}: {_json.dumps(diag, ensure_ascii=False)[:1000]}")) |
| 508 | except Exception as exc: |
| 509 | alipay_logger.warning(_msg("🔍", f"{tag}: 诊断采集失败: {exc}")) |
| 510 | |
| 511 | def _watch_url(url: str) -> bool: |
| 512 | u = (url or "").lower() |
| 513 | return any(k in u for k in ( |
| 514 | "publish", |
| 515 | "publishshortvideo", |
| 516 | "content-creation", |
| 517 | "posts", |
| 518 | "submit", |
| 519 | "short-video", |
| 520 | "captcha.alipay.com/api/v1/captcha/verify", |
| 521 | )) |
| 522 | |
| 523 | async def _collect_response(resp): |
| 524 | try: |
| 525 | if not _watch_url(resp.url): |
| 526 | return |
| 527 | status = resp.status |
| 528 | text = "" |
| 529 | req_body = "" |
| 530 | try: |
| 531 | req_body = (resp.request.post_data or "").strip().replace("\n", " ") |
| 532 | except Exception: |
| 533 | req_body = "" |
| 534 | ct = (resp.headers or {}).get("content-type", "").lower() |
| 535 | if "json" in ct or "text" in ct: |
| 536 | try: |
| 537 | text = (await resp.text() or "").strip().replace("\n", " ") |
| 538 | except Exception: |
| 539 | text = "" |
| 540 | merged = f"req={req_body[:180]} resp={text[:180]}".strip() |
| 541 | net_events.append((resp.request.method, resp.url, status, merged[:380])) |
| 542 | except Exception: |
| 543 | pass |
| 544 | |
| 545 | def _on_response(resp): |
| 546 | try: |
| 547 | net_tasks.append(asyncio.create_task(_collect_response(resp))) |
| 548 | except Exception: |
| 549 | pass |
| 550 | |
| 551 | def _on_request(req): |
| 552 | try: |
| 553 | if not _watch_url(req.url): |
| 554 | return |
| 555 | body = (req.post_data or "").strip().replace("\n", " ") |
| 556 | req_events.append((req.method, req.url, body[:220])) |
| 557 | except Exception: |
| 558 | pass |
| 559 | |
| 560 | page.on("response", _on_response) |
| 561 | page.on("request", _on_request) |
| 562 | async def _dismiss_quality_modal() -> bool: |
| 563 | """点"确认发布"后支付宝可能弹「发现N个优化项」质量提示弹窗(封面断字/ |
| 564 | 标题断字等),拦截真正提交导致 90s 超时。弹窗里「继续发布」才是放行, |
| 565 | 「返回更换」虽是主按钮样式却会退回修改,必须按文案点「继续发布」。 |
| 566 | 返回是否点了「继续发布」。""" |
| 567 | try: |
| 568 | btn = page.locator( |
| 569 | '.antd5-modal-wrap button:has-text("继续发布"), ' |
| 570 | '.antd5-modal button:has-text("继续发布")' |
| 571 | ).first |
| 572 | if await btn.count() and await btn.is_visible(): |
| 573 | await btn.click(timeout=3000) |
| 574 | alipay_logger.info(_msg("✅", "已点击优化项弹窗「继续发布」放行提交")) |
| 575 | await page.wait_for_timeout(500) |
| 576 | return True |
| 577 | except Exception as exc: |
| 578 | alipay_logger.warning(_msg("😵", f"点击「继续发布」失败: {exc}")) |
| 579 | return False |
| 580 | |
| 581 | await _collect_click_diag("点击前") |
| 582 | await publish_btn.click() |
| 583 | await page.wait_for_timeout(600) |
| 584 | await _collect_click_diag("首次点击后") |
| 585 | # 首次点击后可能立即弹出优化项拦截弹窗,先放行一次 |
| 586 | await _dismiss_quality_modal() |
| 587 | |
| 588 | start = time.monotonic() |
| 589 | timeout = 90 |
| 590 | success_toast = page.locator('.antd5-message-notice-content:has-text("发布成功"), .antd5-message-notice-content:has-text("提交成功"), .antd5-message-notice-content:has-text("提交审核"), .antd5-message-notice-content:has-text("审核中")').first |
| 591 | fail_toast = page.locator('.antd5-message-notice-content:has-text("发布失败"), .antd5-message-notice-content:has-text("提交失败"), .antd5-message-notice-content:has-text("请稍后重试")').first |
| 592 | |
| 593 | retried_after_aigc = False |
| 594 | submit_click_retries = 0 |
| 595 | last_click_ts = start |
| 596 | try: |
| 597 | while time.monotonic() - start <= timeout: |
| 598 | if "/content-creation/posts" in page.url: |
| 599 | alipay_logger.success(_msg("🥳", "视频发布成功")) |
| 600 | return |
| 601 | |
| 602 | # 优化项弹窗可能延迟出现,随时拦截提交,随见随点「继续发布」放行 |
| 603 | await _dismiss_quality_modal() |
| 604 | |
| 605 | # 命中 AIGC 预处理链路后,页面通常仍停留在发布页,需要再点一次确认发布才真正提交 |
| 606 | try: |
| 607 | saw_aigc_done = any( |
| 608 | ("querylooptask" in u.lower() and '"done":true' in (b or "").lower()) |
| 609 | for _, u, _, b in net_events |
| 610 | ) |
| 611 | if saw_aigc_done and not retried_after_aigc: |
| 612 | if await publish_btn.count() and not await publish_btn.is_disabled(): |
| 613 | await publish_btn.click() |
| 614 | retried_after_aigc = True |
| 615 | alipay_logger.info(_msg("🔁", "检测到 AIGC 预处理完成,已二次点击确认发布")) |
| 616 | await page.wait_for_timeout(500) |
| 617 | await _collect_click_diag("AIGC后二次点击后") |
| 618 | except Exception: |
| 619 | pass |
| 620 | |
| 621 | # 核心判据:必须至少看到一次 captcha verify / publishShortVideo 请求 |
| 622 | saw_captcha_verify = any("captcha.alipay.com/api/v1/captcha/verify" in u.lower() for _, u, _, _ in net_events) |
| 623 | saw_publish_submit = any("publishshortvideo.json" in u.lower() for _, u, _, _ in net_events) |
| 624 | no_submit_signal = not (saw_captcha_verify or saw_publish_submit) |
| 625 | if no_submit_signal and submit_click_retries < 3 and (time.monotonic() - last_click_ts) >= 8: |
| 626 | try: |
| 627 | if await publish_btn.count() and not await publish_btn.is_disabled(): |
| 628 | await publish_btn.click() |
| 629 | submit_click_retries += 1 |
| 630 | last_click_ts = time.monotonic() |
| 631 | alipay_logger.info(_msg("🔁", f"未检测到提交请求信号,重试点击确认发布({submit_click_retries}/3)")) |
| 632 | await page.wait_for_timeout(500) |
| 633 | await _collect_click_diag(f"重试点击后#{submit_click_retries}") |
| 634 | except Exception: |
| 635 | pass |
| 636 | |
| 637 | try: |
| 638 | if await success_toast.count() and await success_toast.is_visible(): |
| 639 | alipay_logger.success(_msg("🥳", "视频发布成功(toast 命中)")) |
| 640 | return |
| 641 | except Exception: |
| 642 | pass |
| 643 | try: |
| 644 | if await fail_toast.count() and await fail_toast.is_visible(): |
| 645 | raise RuntimeError("发布失败(页面返回失败提示)") |
| 646 | except RuntimeError: |
| 647 | raise |
| 648 | except Exception: |
| 649 | pass |
| 650 | await page.wait_for_timeout(1000) |
| 651 | |
| 652 | raise RuntimeError( |
| 653 | f"发布后未检测到成功信号(90s),当前地址: {page.url}" |
| 654 | ) |
| 655 | finally: |
| 656 | page.remove_listener("response", _on_response) |
| 657 | page.remove_listener("request", _on_request) |
| 658 | if net_tasks: |
| 659 | try: |
| 660 | await asyncio.wait(net_tasks, timeout=3) |
| 661 | except Exception: |
| 662 | pass |
| 663 | if req_events: |
| 664 | alipay_logger.info(_msg("🧾", f"提交阶段请求事件 {len(req_events)} 条(最近10条)")) |
| 665 | for m, u, b in req_events[-10:]: |
| 666 | alipay_logger.info(_msg("🧾", f"REQ {m} {u} | {b}")) |
| 667 | if net_events: |
| 668 | alipay_logger.info(_msg("🧾", f"提交阶段网络事件 {len(net_events)} 条(最近10条)")) |
| 669 | for m, u, s, b in net_events[-10:]: |
| 670 | alipay_logger.info(_msg("🧾", f"{m} {s} {u} | {b}")) |
| 671 | |
| 672 | async def upload(self, playwright: Playwright) -> None: |
| 673 | alipay_logger.info(_msg("🧍", "先检查 cookie 和视频文件")) |
| 674 | await self.validate_upload_args() |
| 675 | alipay_logger.info(_msg("🥳", "上传前检查通过")) |
| 676 | |
| 677 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless)) |
| 678 | context = await browser.new_context(storage_state=self.account_file) |
| 679 | await context.grant_permissions(["geolocation"]) |
| 680 | # 注意:不能用 set_init_script(stealth) —— 会阻止支付宝内容创作平台(qiankun 微应用)渲染 |
| 681 | |
| 682 | try: |
| 683 | page = await context.new_page() |
| 684 | await self.open_upload_page(page) |
| 685 | alipay_logger.info(_msg("🏃", f"开始上传视频: {self.title}")) |
| 686 | |
| 687 | await self.upload_video_file(page, self.file_path) |
| 688 | await self.fill_title_and_tags(page) |
| 689 | await self.fill_description(page) |
| 690 | await self.upload_thumbnail(page) |
| 691 | await self.apply_collection(page) |
| 692 | await self.check_ai_label(page) |
| 693 | await self.wait_for_upload_complete(page) |
| 694 | await self.submit_publish(page) |
| 695 | |
| 696 | await context.storage_state(path=self.account_file) |
| 697 | alipay_logger.success(_msg("🥳", "cookie 更新完毕")) |
| 698 | finally: |
| 699 | await context.close() |
| 700 | await browser.close() |
| 701 | |
| 702 | async def alipay_upload_video(self): |
| 703 | async with async_playwright() as playwright: |
| 704 | await self.upload(playwright) |
| 705 | |
| 706 | async def main(self): |
| 707 | await self.alipay_upload_video() |
| 708 |