返回 Social Auto Upload
main.py
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
708 lines PYTHON