mirror of
https://github.com/dreammis/social-auto-upload.git
synced 2026-08-28 17:43:28 +08:00
feat(alipay): add Alipay life account video uploader
- New uploader/alipay_uploader module: QR-code cookie login, upload page flow, title/desc/tags fill, thumbnail, collection and publish - sau_cli: add 'alipay login/check/upload-video' subcommands - examples: get_alipay_cookie.py and test_alipay_upload.py - utils/log.py: register alipay logger - utils/login_qrcode.py: robust QR decode on non-ASCII Windows paths (numpy imdecode with cv2.imread fallback)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""获取支付宝生活号 cookie:打开浏览器扫码登录后自动保存到 cookies/alipay_uploader/account.json"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from uploader.alipay_uploader.main import alipay_cookie_gen
|
||||
|
||||
|
||||
async def main():
|
||||
account_file = Path(__file__).resolve().parents[1] / "cookies" / "alipay_uploader" / "account.json"
|
||||
await alipay_cookie_gen(str(account_file))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试支付宝生活号上传:使用已保存的 cookie 上传并发布一个视频。"""
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from uploader.alipay_uploader.main import AlipayVideo
|
||||
|
||||
|
||||
async def main():
|
||||
base = Path(__file__).resolve().parents[1]
|
||||
video_path = Path(r"D:\video-moving\assets\outro\outro_horizontal.mp4")
|
||||
account_file = base / "cookies" / "alipay_uploader" / "account.json"
|
||||
|
||||
app = AlipayVideo(
|
||||
title="测试发布标题",
|
||||
file_path=str(video_path),
|
||||
tags=["测试", "自动化"],
|
||||
account_file=str(account_file),
|
||||
desc="测试描述",
|
||||
thumbnail_path=r"C:\Users\admin\Downloads\ScreenShot_2026-08-04_170159_630.png",
|
||||
collection_name="",
|
||||
headless=False,
|
||||
)
|
||||
await app.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+103
@@ -9,6 +9,11 @@ from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from conf import BASE_DIR
|
||||
from uploader.alipay_uploader.main import (
|
||||
AlipayVideo,
|
||||
alipay_setup,
|
||||
cookie_auth as alipay_cookie_auth,
|
||||
)
|
||||
from uploader.bilibili_uploader.runtime import run_biliup_command
|
||||
from uploader.douyin_uploader.main import (
|
||||
DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
||||
@@ -168,6 +173,19 @@ class TencentVideoUploadRequest:
|
||||
headless: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlipayVideoUploadRequest:
|
||||
account_name: str
|
||||
video_file: Path
|
||||
title: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
thumbnail_file: Path | None = None
|
||||
collection_name: str | None = None
|
||||
debug: bool = True
|
||||
headless: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class YouTubeVideoUploadRequest:
|
||||
account_name: str
|
||||
@@ -549,6 +567,41 @@ async def upload_tencent_video(request: TencentVideoUploadRequest) -> Path:
|
||||
return account_file
|
||||
|
||||
|
||||
async def login_alipay_account(account_name: str, headless: bool = True, qrcode_callback=None) -> dict:
|
||||
account_file = resolve_account_file("alipay", account_name)
|
||||
return await alipay_setup(str(account_file), handle=True, return_detail=True, headless=headless, qrcode_callback=qrcode_callback)
|
||||
|
||||
|
||||
async def check_alipay_account(account_name: str) -> bool:
|
||||
account_file = resolve_account_file("alipay", account_name)
|
||||
if not account_file.exists():
|
||||
return False
|
||||
return await alipay_cookie_auth(str(account_file))
|
||||
|
||||
|
||||
async def upload_alipay_video(request: AlipayVideoUploadRequest) -> Path:
|
||||
account_file = resolve_account_file("alipay", request.account_name)
|
||||
is_ready = await alipay_setup(str(account_file), handle=False)
|
||||
if not is_ready:
|
||||
raise RuntimeError(
|
||||
f"Alipay cookie is missing or expired: {account_file}. Run `sau alipay login --account {request.account_name}` first."
|
||||
)
|
||||
|
||||
app = AlipayVideo(
|
||||
title=request.title,
|
||||
file_path=str(request.video_file),
|
||||
tags=request.tags,
|
||||
account_file=str(account_file),
|
||||
desc=request.description,
|
||||
thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
|
||||
collection_name=request.collection_name,
|
||||
debug=request.debug,
|
||||
headless=request.headless,
|
||||
)
|
||||
await app.main()
|
||||
return account_file
|
||||
|
||||
|
||||
def existing_file_path(value: str) -> Path:
|
||||
path = Path(value)
|
||||
if not path.is_file():
|
||||
@@ -716,6 +769,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
tencent_upload_video_parser.add_argument("--draft", action="store_true", help="Save as draft instead of publishing")
|
||||
add_runtime_flags(tencent_upload_video_parser)
|
||||
|
||||
alipay_parser = platform_parsers.add_parser("alipay", help="Alipay life account operations")
|
||||
alipay_actions = alipay_parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
for action_name in ("login", "check"):
|
||||
action_parser = alipay_actions.add_parser(action_name, help=f"Alipay {action_name}")
|
||||
action_parser.add_argument("--account", required=True, help="Alipay user-defined account_name")
|
||||
if action_name == "login":
|
||||
add_runtime_flags(action_parser)
|
||||
|
||||
alipay_upload_video_parser = alipay_actions.add_parser("upload-video", help="Upload one video to Alipay")
|
||||
alipay_upload_video_parser.add_argument("--account", required=True, help="Alipay user-defined account_name")
|
||||
alipay_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
|
||||
alipay_upload_video_parser.add_argument("--title", required=True, help="Video title")
|
||||
alipay_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
|
||||
alipay_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
|
||||
alipay_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional cover image path")
|
||||
alipay_upload_video_parser.add_argument("--collection", default=None, help="Optional collection name to add the work into (must already exist)")
|
||||
add_runtime_flags(alipay_upload_video_parser)
|
||||
|
||||
youtube_parser = platform_parsers.add_parser("youtube", help="YouTube operations")
|
||||
youtube_actions = youtube_parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
@@ -984,6 +1056,37 @@ async def dispatch(args: argparse.Namespace) -> int:
|
||||
|
||||
raise RuntimeError(f"Unsupported Tencent/WeChat Channels action: {args.action}")
|
||||
|
||||
if args.platform == "alipay":
|
||||
if args.action == "login":
|
||||
result = await login_alipay_account(args.account, headless=args.headless)
|
||||
if not result["success"]:
|
||||
raise RuntimeError(result["message"])
|
||||
print(f"Alipay login flow completed: {result['account_file']}")
|
||||
return 0
|
||||
|
||||
if args.action == "check":
|
||||
is_valid = await check_alipay_account(args.account)
|
||||
print("valid" if is_valid else "invalid")
|
||||
return 0 if is_valid else 1
|
||||
|
||||
if args.action == "upload-video":
|
||||
request = AlipayVideoUploadRequest(
|
||||
account_name=args.account,
|
||||
video_file=args.file,
|
||||
title=args.title,
|
||||
description=args.desc,
|
||||
tags=parse_tags(args.tags),
|
||||
thumbnail_file=args.thumbnail,
|
||||
collection_name=args.collection,
|
||||
debug=args.debug,
|
||||
headless=args.headless,
|
||||
)
|
||||
await upload_alipay_video(request)
|
||||
print(f"Alipay video upload submitted: {request.video_file}")
|
||||
return 0
|
||||
|
||||
raise RuntimeError(f"Unsupported Alipay action: {args.action}")
|
||||
|
||||
if args.platform == "youtube":
|
||||
if args.action == "login":
|
||||
result = await login_youtube_account(args.account, headless=args.headless)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from conf import BASE_DIR
|
||||
|
||||
Path(BASE_DIR / "cookies" / "alipay_uploader").mkdir(exist_ok=True)
|
||||
@@ -0,0 +1,707 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json as _json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.async_api import Page, Playwright, TimeoutError as PWTimeoutError, async_playwright
|
||||
|
||||
from conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
|
||||
from uploader.base_video import BaseVideoUploader
|
||||
from utils.base_social_media import set_init_script
|
||||
from utils.log import alipay_logger
|
||||
from utils.login_qrcode import build_login_qrcode_path
|
||||
from utils.login_qrcode import decode_qrcode_from_path
|
||||
from utils.login_qrcode import print_terminal_qrcode
|
||||
from utils.login_qrcode import remove_qrcode_file
|
||||
|
||||
ALIPAY_HOME_URL = "https://c.alipay.com/"
|
||||
ALIPAY_PORTAL_HOME = "https://c.alipay.com/page/portal/home"
|
||||
# 内容创作平台入口:必须带 _appScene=CONTENT&appId=xxx,否则会跳到生活号开通页 signup
|
||||
ALIPAY_LIFE_ACCOUNT_URL = "https://c.alipay.com/page/life-account/index?_appScene=CONTENT&appId=2030022469359777"
|
||||
ALIPAY_POSTS_URL = "https://c.alipay.com/page/content-creation/posts"
|
||||
|
||||
|
||||
def _msg(emoji: str, text: str) -> str:
|
||||
return f"{emoji} {text}"
|
||||
|
||||
|
||||
def _build_login_result(success: bool, status: str, message: str, account_file: str, qrcode: dict | None = None, current_url: str = "") -> dict:
|
||||
return {
|
||||
"success": success,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"account_file": str(account_file),
|
||||
"qrcode": qrcode,
|
||||
"current_url": current_url,
|
||||
}
|
||||
|
||||
|
||||
async def _emit_qrcode_callback(qrcode_callback, payload: dict):
|
||||
if not qrcode_callback:
|
||||
return
|
||||
callback_result = qrcode_callback(payload)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
|
||||
|
||||
def _build_launch_kwargs(headless: bool) -> dict:
|
||||
launch_kwargs = {"headless": headless}
|
||||
if LOCAL_CHROME_PATH:
|
||||
launch_kwargs["executable_path"] = LOCAL_CHROME_PATH
|
||||
return launch_kwargs
|
||||
|
||||
|
||||
def _resolve_account_file(account_file: str | Path) -> str:
|
||||
path = Path(account_file).expanduser()
|
||||
if path.is_absolute():
|
||||
return str(path)
|
||||
|
||||
if len(path.parts) == 1:
|
||||
return str((Path(BASE_DIR) / "cookies" / "alipay_uploader" / path).resolve())
|
||||
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
def format_title_with_tags(title: str, tags: list[str], max_length: int = 30) -> str:
|
||||
"""支付宝生活号的标签是 #tag 格式,拼在标题末尾一起填入标题输入框。
|
||||
|
||||
按标签边界截断:先保证标题完整,再逐个追加标签,加不下的**整个标签丢弃**,
|
||||
绝不留半个标签或裸 `#`。这避免触发支付宝"断字"优化项弹窗阻断发布。
|
||||
"""
|
||||
if not tags:
|
||||
return title[:max_length]
|
||||
result = title
|
||||
for tag in tags:
|
||||
candidate = result + " #" + tag.strip("#")
|
||||
if len(candidate) > max_length:
|
||||
break
|
||||
result = candidate
|
||||
return result
|
||||
|
||||
|
||||
async def _capture_alipay_qr(page: Page, account_file: str, previous_qrcode_path: Path | None = None) -> dict:
|
||||
"""严格按支付宝登录页固定 DOM 截二维码(只认扫码 tab + barcode canvas)。"""
|
||||
login_iframe = page.locator('iframe[title="login"]')
|
||||
await login_iframe.first.wait_for(state="attached", timeout=30000)
|
||||
frame = page.frame_locator('iframe[title="login"]')
|
||||
|
||||
try:
|
||||
await frame.locator("#J-loginMethod-tabs").first.wait_for(state="visible", timeout=15000)
|
||||
# 固定切到扫码 tab(若本来已是扫码,重复点击无副作用)
|
||||
await frame.locator("#J-loginMethod-tabs li[data-status='show_qr']").first.click(timeout=5000)
|
||||
except PWTimeoutError:
|
||||
# 兼容另一种渲染:直接落在扫码区(无 tabs)
|
||||
pass
|
||||
|
||||
# 只认二维码容器,不认账密元素
|
||||
await frame.locator("#J-qrcode, #J-barcode-container").first.wait_for(state="visible", timeout=25000)
|
||||
await frame.locator("#J-barcode-container canvas.barcode, #J-barcode-container canvas").first.wait_for(state="visible", timeout=25000)
|
||||
|
||||
# 账密区域在扫码模式下应隐藏
|
||||
try:
|
||||
login_panel = frame.locator("#J-login")
|
||||
if await login_panel.count():
|
||||
klass = (await login_panel.first.get_attribute("class") or "")
|
||||
if "fn-hide" not in klass:
|
||||
raise RuntimeError("当前仍处于账密登录面板(#J-login 未隐藏)")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
qrcode_path = build_login_qrcode_path(account_file)
|
||||
qrcode_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
qr_canvas = frame.locator("#J-barcode-container canvas.barcode, #J-barcode-container canvas").first
|
||||
await qr_canvas.screenshot(path=str(qrcode_path), timeout=15000)
|
||||
|
||||
qrcode_content = decode_qrcode_from_path(qrcode_path)
|
||||
if previous_qrcode_path and previous_qrcode_path != qrcode_path:
|
||||
if remove_qrcode_file(previous_qrcode_path):
|
||||
alipay_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
|
||||
alipay_logger.info(_msg("🖼️", f"二维码已保存到: {qrcode_path}"))
|
||||
if qrcode_content:
|
||||
print_terminal_qrcode(qrcode_content, qrcode_path, "支付宝APP")
|
||||
else:
|
||||
alipay_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码"))
|
||||
return {"image_path": str(qrcode_path), "image_data_url": ""}
|
||||
|
||||
|
||||
async def alipay_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 100, headless: bool = LOCAL_CHROME_HEADLESS):
|
||||
"""打开浏览器,用户扫码登录支付宝生活号,登录成功后保存 cookie(镜像 douyin_cookie_gen)。
|
||||
|
||||
二维码 png 落 cookies 目录(*login_qrcode*.png)供终端显示/告知位置;qrcode_callback 可选(如 relogin 推飞书)。
|
||||
headless=False 时也可直接在弹出的浏览器里扫码。
|
||||
返回 _build_login_result 结果 dict。
|
||||
"""
|
||||
account_file = _resolve_account_file(account_file)
|
||||
Path(account_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
qrcode_path = None
|
||||
result = _build_login_result(False, "failed", "支付宝登录失败", account_file)
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless))
|
||||
context = await browser.new_context()
|
||||
try:
|
||||
page = await context.new_page()
|
||||
# 注意:不能用 set_init_script(stealth) —— 实验证明 stealth 会阻止支付宝登录 iframe 注入
|
||||
# 先进受保护的后台页,未登录会触发登录弹窗(iframe[title="login"],异步注入约 2~4s)
|
||||
await page.goto(ALIPAY_PORTAL_HOME, timeout=60000, wait_until="domcontentloaded")
|
||||
if headless:
|
||||
alipay_logger.info(_msg("🧍", "无头登录中:二维码已存为图片,请在终端扫或打开图片扫码(或由发布进程推送到飞书)"))
|
||||
else:
|
||||
alipay_logger.info(_msg("🧍", "请在打开的浏览器中扫码登录支付宝生活号(登录完成后请勿手动关闭浏览器)"))
|
||||
|
||||
# 等登录 iframe 出现
|
||||
login_iframe = page.locator('iframe[title="login"]')
|
||||
for _ in range(30):
|
||||
try:
|
||||
if await login_iframe.count():
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(1000)
|
||||
else:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return _build_login_result(False, "timeout", "等待登录弹窗超时(30s),放弃保存", account_file, current_url=page.url)
|
||||
|
||||
# 截图二维码(无头时给终端/飞书;有头时也截一份备用,不致命)
|
||||
qrcode_info = await _capture_alipay_qr(page, account_file)
|
||||
qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None
|
||||
await _emit_qrcode_callback(qrcode_callback, qrcode_info)
|
||||
alipay_logger.info(_msg("🧍", "请扫码,正在耐心等待登录完成"))
|
||||
|
||||
# 轮询等待登录完成:登录 iframe 消失(登录成功后 auth 页会跳走)
|
||||
for _i in range(max_checks): # 最多等 3~5 分钟
|
||||
if await _is_alipay_login_completed(page, login_iframe):
|
||||
alipay_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}"))
|
||||
result = _build_login_result(True, "success", "支付宝扫码登录成功", account_file, qrcode_info, page.url)
|
||||
break
|
||||
await page.wait_for_timeout(poll_interval * 1000)
|
||||
else:
|
||||
result = _build_login_result(False, "timeout", "等待支付宝扫码登录超时", account_file, qrcode_info, page.url)
|
||||
|
||||
if result["success"]:
|
||||
await asyncio.sleep(2)
|
||||
await context.storage_state(path=account_file)
|
||||
# 登录结束,轻量确认 cookie 文件里有点东西
|
||||
try:
|
||||
_d = _json.load(open(account_file))
|
||||
_has_cookie = any(c.get("value") for c in _d.get("cookies", []))
|
||||
if not _has_cookie:
|
||||
result = _build_login_result(False, "cookie_invalid", "支付宝扫码流程结束,但 cookie 为空", account_file, qrcode_info, page.url)
|
||||
except Exception as _e:
|
||||
alipay_logger.warning(_msg("⚠️", f"cookie 文件校验异常(忽略,按成功处理): {_e}"))
|
||||
except Exception as exc:
|
||||
result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "")
|
||||
finally:
|
||||
if remove_qrcode_file(qrcode_path):
|
||||
alipay_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
|
||||
if not result["success"]:
|
||||
alipay_logger.error(_msg("😢", f"登录失败: {result['message']}"))
|
||||
await context.close()
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
|
||||
async def _is_alipay_login_completed(page: Page, login_iframe) -> bool:
|
||||
# 登录成功判定:登录 iframe 消失(登录成功后 auth 页会跳走),且 URL 回到 c.alipay.com 后台不算登录页
|
||||
try:
|
||||
if await login_iframe.count() != 0:
|
||||
return False
|
||||
url = page.url
|
||||
if url.startswith("https://c.alipay.com/") and "login" not in url.lower():
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def cookie_auth(account_file):
|
||||
account_file = _resolve_account_file(account_file)
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True))
|
||||
try:
|
||||
context = await browser.new_context(storage_state=account_file)
|
||||
page = await context.new_page()
|
||||
await page.goto(ALIPAY_LIFE_ACCOUNT_URL, timeout=60000, wait_until="domcontentloaded")
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
# 登录会整页跳 auth.alipay.com;仍停留在 c.alipay.com 且不在 login 页才算有效
|
||||
url = page.url
|
||||
if url.startswith("https://auth.alipay.com/") or "login" in url.lower():
|
||||
alipay_logger.info(_msg("🥹", "cookie 已失效(跳转到登录页)"))
|
||||
return False
|
||||
if await page.locator('iframe[title="login"]').count():
|
||||
alipay_logger.info(_msg("🥹", "cookie 已失效(出现登录弹窗)"))
|
||||
return False
|
||||
|
||||
alipay_logger.success(_msg("🥳", "cookie 有效"))
|
||||
return True
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
|
||||
return False
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def alipay_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS):
|
||||
account_file = _resolve_account_file(account_file)
|
||||
if not os.path.exists(account_file) or not await cookie_auth(account_file):
|
||||
if not handle:
|
||||
result = _build_login_result(False, "cookie_invalid", "cookie 文件不存在或已失效", account_file)
|
||||
return result if return_detail else False
|
||||
alipay_logger.info(_msg("🥹", "cookie 文件不存在或已失效,自动打开浏览器请扫码登录"))
|
||||
result = await alipay_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
|
||||
return result if return_detail else result["success"]
|
||||
|
||||
result = _build_login_result(True, "cookie_valid", "cookie 有效", account_file)
|
||||
return result if return_detail else True
|
||||
|
||||
|
||||
class AlipayVideo(BaseVideoUploader):
|
||||
def __init__(
|
||||
self,
|
||||
title,
|
||||
file_path,
|
||||
tags,
|
||||
account_file,
|
||||
desc: str | None = None,
|
||||
thumbnail_path: str | None = None,
|
||||
collection_name: str | None = None,
|
||||
debug: bool = True,
|
||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||
):
|
||||
self.title = title
|
||||
self.file_path = file_path
|
||||
self.tags = tags or []
|
||||
self.account_file = _resolve_account_file(account_file)
|
||||
self.desc = desc or ""
|
||||
self.thumbnail_path = thumbnail_path
|
||||
self.collection_name = collection_name
|
||||
self.debug = debug
|
||||
self.headless = headless
|
||||
self.local_executable_path = LOCAL_CHROME_PATH
|
||||
self.max_title_length = 30
|
||||
|
||||
async def validate_upload_args(self):
|
||||
if not os.path.exists(self.account_file):
|
||||
raise RuntimeError(f"cookie文件不存在,请先完成支付宝生活号登录: {self.account_file}")
|
||||
if not await cookie_auth(self.account_file):
|
||||
raise RuntimeError(f"cookie文件已失效,请先完成支付宝生活号登录: {self.account_file}")
|
||||
if not self.title or not str(self.title).strip():
|
||||
raise ValueError("视频模式下,title 是必须的")
|
||||
self.file_path = str(self.validate_video_file(self.file_path))
|
||||
if self.thumbnail_path:
|
||||
self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
|
||||
|
||||
async def open_upload_page(self, page: Page) -> None:
|
||||
# 进内容创作平台首页,点"发布视频"卡片(JS 跳转)进入短视频发布表单
|
||||
await page.goto(ALIPAY_LIFE_ACCOUNT_URL, timeout=120000, wait_until="domcontentloaded")
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
publish_entry = page.locator('a:has-text("发布视频推荐分辨率720p及以上,建议1080p")').first
|
||||
try:
|
||||
await publish_entry.wait_for(state="visible", timeout=30000)
|
||||
await publish_entry.click()
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("😵", f"点击「发布视频」入口失败: {exc}"))
|
||||
raise
|
||||
|
||||
await page.wait_for_url("**/content-creation/publish/short-video**", timeout=60000)
|
||||
|
||||
async def upload_video_file(self, page: Page, file_path: str) -> None:
|
||||
file_input = page.locator('input[type="file"]').first
|
||||
await file_input.wait_for(state="attached", timeout=30000)
|
||||
await file_input.set_input_files(file_path)
|
||||
alipay_logger.info(_msg("🏃", f"已选择视频文件: {file_path}"))
|
||||
|
||||
async def fill_title_and_tags(self, page: Page) -> None:
|
||||
title_field = page.get_by_placeholder("一个好的标题,能获得更多人的喜欢哦").first
|
||||
await title_field.wait_for(state="visible", timeout=30000)
|
||||
value = format_title_with_tags(self.title, self.tags, max_length=self.max_title_length)
|
||||
await title_field.fill(value)
|
||||
alipay_logger.info(_msg("🏷️", f"标题已填写(含标签共 {len(value)} 字): {value}"))
|
||||
|
||||
async def fill_description(self, page: Page) -> None:
|
||||
if not self.desc:
|
||||
return
|
||||
desc_field = page.get_by_placeholder("填写作品描述,让你的作品更容易被看到").first
|
||||
await desc_field.fill(self.desc)
|
||||
alipay_logger.info(_msg("🏷️", f"作品描述已填写: {self.desc[:40]}"))
|
||||
|
||||
async def upload_thumbnail(self, page: Page) -> None:
|
||||
if not self.thumbnail_path:
|
||||
return
|
||||
try:
|
||||
# 1) 点发布表单的"上传封面"入口,弹出"截取封面"弹窗
|
||||
cover_el = page.get_by_text("上传封面", exact=True).first
|
||||
await cover_el.scroll_into_view_if_needed()
|
||||
await cover_el.click(timeout=10000)
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
modal_body = page.locator(".antd5-modal-body").last
|
||||
|
||||
# 2) 弹窗首次是"截取封面/上传封面"两入口;点"上传封面"展开图片上传区
|
||||
inner = modal_body.get_by_text("上传封面", exact=True).first
|
||||
if await inner.count():
|
||||
await inner.click(timeout=8000)
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
# 3) 点"上传图片"打开图片选择器
|
||||
upload_img_btn = modal_body.get_by_role("button", name="上传图片").first
|
||||
await upload_img_btn.wait_for(state="visible", timeout=10000)
|
||||
await upload_img_btn.click()
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
# 4) 设置图片到新出现的图片 file input
|
||||
img_input = page.locator('input[type="file"][accept*="jpg"], input[type="file"][accept*="png"]').first
|
||||
await img_input.wait_for(state="attached", timeout=10000)
|
||||
await img_input.set_input_files(self.thumbnail_path)
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# 5) 裁剪弹窗里点"完 成"
|
||||
done_btn = page.get_by_role("button", name="完 成").first
|
||||
if not await done_btn.count():
|
||||
done_btn = page.get_by_role("button", name="完成").first
|
||||
await done_btn.wait_for(state="visible", timeout=15000)
|
||||
await done_btn.click()
|
||||
await page.wait_for_timeout(800)
|
||||
alipay_logger.success(_msg("🖼️", "封面已上传"))
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("😵", f"封面上传失败,跳过继续: {exc}"))
|
||||
|
||||
async def apply_collection(self, page: Page) -> None:
|
||||
if not self.collection_name:
|
||||
return
|
||||
try:
|
||||
# antd5-select:点击合集下拉的可点开关(compilation input 的父容器),展开选项
|
||||
compilation = page.locator('input[id*="_compilationInfo"]').first
|
||||
await compilation.scroll_into_view_if_needed()
|
||||
select = compilation.locator("xpath=../../..").first
|
||||
if not await select.count():
|
||||
select = page.locator(
|
||||
'.antd5-select:has(.antd5-select-selection-placeholder)'
|
||||
).first
|
||||
await select.click(timeout=8000)
|
||||
await page.wait_for_timeout(2000)
|
||||
|
||||
# 只在选项里精确找映射合集名;找不到就不归集(用户后续手工建合集后会自动选中)
|
||||
options = page.locator('[role="option"]')
|
||||
target = options.filter(has_text=self.collection_name).first
|
||||
if await target.count():
|
||||
try:
|
||||
await target.click(timeout=5000, force=True)
|
||||
except Exception:
|
||||
await target.scroll_into_view_if_needed()
|
||||
await target.click(timeout=5000)
|
||||
await page.wait_for_timeout(800)
|
||||
alipay_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}"))
|
||||
return
|
||||
|
||||
alipay_logger.warning(_msg("😵", f"账号中无「{self.collection_name}」合集,跳过归集"))
|
||||
await page.keyboard.press("Escape")
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}"))
|
||||
|
||||
async def check_ai_label(self, page: Page) -> None:
|
||||
# 作者声明是 radio 组(默认"内容无需标注"NO_STATEMENT),用 radio.check() 选中"内容由AI生成"(A_AG3)
|
||||
ai_label = page.locator('label.antd5-radio-wrapper', has_text="内容由AI生成").first
|
||||
try:
|
||||
if await ai_label.count():
|
||||
radio = ai_label.locator('input[type="radio"]').first
|
||||
await radio.scroll_into_view_if_needed()
|
||||
await radio.check(timeout=5000)
|
||||
await page.wait_for_timeout(500)
|
||||
alipay_logger.success(_msg("🏷️", "已勾选「内容由AI生成」"))
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("😵", f"勾选「内容由AI生成」失败: {exc}"))
|
||||
|
||||
async def wait_for_upload_complete(self, page: Page, timeout: int = 1800) -> None:
|
||||
# 等待"确认发布"按钮变为可点击(视频上传+转码完成)
|
||||
publish_btn = page.get_by_role("button", name="确认发布").first
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
if time.monotonic() - start > timeout:
|
||||
raise TimeoutError(f"等待视频上传/转码超时(>{timeout}s),确认发布按钮始终不可用")
|
||||
try:
|
||||
if not await publish_btn.count():
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
if await publish_btn.is_disabled():
|
||||
alipay_logger.info(_msg("🏃", "正在上传/转码视频中..."))
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
alipay_logger.success(_msg("🥳", "视频上传完毕"))
|
||||
return
|
||||
except Exception:
|
||||
alipay_logger.info(_msg("🏃", "正在上传/转码视频中..."))
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def submit_publish(self, page: Page) -> None:
|
||||
publish_btn = page.get_by_role("button", name="确认发布").first
|
||||
await publish_btn.wait_for(state="visible", timeout=30000)
|
||||
# 某些账号下点击后不会立即跳 posts,需同时观察成功提示文案
|
||||
if await publish_btn.is_disabled():
|
||||
raise RuntimeError("确认发布按钮仍不可点击,无法提交")
|
||||
|
||||
# 只抓提交发布阶段的关键请求,失败时用于定位根因
|
||||
net_events: list[tuple[str, str, int | None, str]] = []
|
||||
req_events: list[tuple[str, str, str]] = []
|
||||
net_tasks: list[asyncio.Task] = []
|
||||
|
||||
async def _collect_click_diag(tag: str):
|
||||
try:
|
||||
diag = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const btns = Array.from(document.querySelectorAll('button')).filter(b => (b.innerText || '').includes('确认发布'));
|
||||
const confirmButtons = btns.map((b, i) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
const topEl = document.elementFromPoint(cx, cy);
|
||||
return {
|
||||
i,
|
||||
text: (b.innerText || '').trim(),
|
||||
disabled: !!b.disabled,
|
||||
ariaDisabled: b.getAttribute('aria-disabled'),
|
||||
className: b.className,
|
||||
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
|
||||
topElement: topEl ? `${topEl.tagName}.${topEl.className || ''}` : null,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleModals = Array.from(document.querySelectorAll('.antd5-modal-wrap, .antd5-message, .antd5-notification')).filter(el => {
|
||||
const st = window.getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return st.display !== 'none' && st.visibility !== 'hidden' && r.width > 0 && r.height > 0;
|
||||
}).slice(0, 5).map(el => ({
|
||||
cls: el.className,
|
||||
text: (el.textContent || '').trim().slice(0, 120),
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
confirmButtons,
|
||||
visibleModals,
|
||||
errorHints,
|
||||
activeElement: document.activeElement ? `${document.activeElement.tagName}.${document.activeElement.className || ''}` : null,
|
||||
};
|
||||
}
|
||||
"""
|
||||
)
|
||||
alipay_logger.info(_msg("🔍", f"{tag}: {_json.dumps(diag, ensure_ascii=False)[:1000]}"))
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("🔍", f"{tag}: 诊断采集失败: {exc}"))
|
||||
|
||||
def _watch_url(url: str) -> bool:
|
||||
u = (url or "").lower()
|
||||
return any(k in u for k in (
|
||||
"publish",
|
||||
"publishshortvideo",
|
||||
"content-creation",
|
||||
"posts",
|
||||
"submit",
|
||||
"short-video",
|
||||
"captcha.alipay.com/api/v1/captcha/verify",
|
||||
))
|
||||
|
||||
async def _collect_response(resp):
|
||||
try:
|
||||
if not _watch_url(resp.url):
|
||||
return
|
||||
status = resp.status
|
||||
text = ""
|
||||
req_body = ""
|
||||
try:
|
||||
req_body = (resp.request.post_data or "").strip().replace("\n", " ")
|
||||
except Exception:
|
||||
req_body = ""
|
||||
ct = (resp.headers or {}).get("content-type", "").lower()
|
||||
if "json" in ct or "text" in ct:
|
||||
try:
|
||||
text = (await resp.text() or "").strip().replace("\n", " ")
|
||||
except Exception:
|
||||
text = ""
|
||||
merged = f"req={req_body[:180]} resp={text[:180]}".strip()
|
||||
net_events.append((resp.request.method, resp.url, status, merged[:380]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_response(resp):
|
||||
try:
|
||||
net_tasks.append(asyncio.create_task(_collect_response(resp)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_request(req):
|
||||
try:
|
||||
if not _watch_url(req.url):
|
||||
return
|
||||
body = (req.post_data or "").strip().replace("\n", " ")
|
||||
req_events.append((req.method, req.url, body[:220]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", _on_response)
|
||||
page.on("request", _on_request)
|
||||
async def _dismiss_quality_modal() -> bool:
|
||||
"""点"确认发布"后支付宝可能弹「发现N个优化项」质量提示弹窗(封面断字/
|
||||
标题断字等),拦截真正提交导致 90s 超时。弹窗里「继续发布」才是放行,
|
||||
「返回更换」虽是主按钮样式却会退回修改,必须按文案点「继续发布」。
|
||||
返回是否点了「继续发布」。"""
|
||||
try:
|
||||
btn = page.locator(
|
||||
'.antd5-modal-wrap button:has-text("继续发布"), '
|
||||
'.antd5-modal button:has-text("继续发布")'
|
||||
).first
|
||||
if await btn.count() and await btn.is_visible():
|
||||
await btn.click(timeout=3000)
|
||||
alipay_logger.info(_msg("✅", "已点击优化项弹窗「继续发布」放行提交"))
|
||||
await page.wait_for_timeout(500)
|
||||
return True
|
||||
except Exception as exc:
|
||||
alipay_logger.warning(_msg("😵", f"点击「继续发布」失败: {exc}"))
|
||||
return False
|
||||
|
||||
await _collect_click_diag("点击前")
|
||||
await publish_btn.click()
|
||||
await page.wait_for_timeout(600)
|
||||
await _collect_click_diag("首次点击后")
|
||||
# 首次点击后可能立即弹出优化项拦截弹窗,先放行一次
|
||||
await _dismiss_quality_modal()
|
||||
|
||||
start = time.monotonic()
|
||||
timeout = 90
|
||||
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
|
||||
fail_toast = page.locator('.antd5-message-notice-content:has-text("发布失败"), .antd5-message-notice-content:has-text("提交失败"), .antd5-message-notice-content:has-text("请稍后重试")').first
|
||||
|
||||
retried_after_aigc = False
|
||||
submit_click_retries = 0
|
||||
last_click_ts = start
|
||||
try:
|
||||
while time.monotonic() - start <= timeout:
|
||||
if "/content-creation/posts" in page.url:
|
||||
alipay_logger.success(_msg("🥳", "视频发布成功"))
|
||||
return
|
||||
|
||||
# 优化项弹窗可能延迟出现,随时拦截提交,随见随点「继续发布」放行
|
||||
await _dismiss_quality_modal()
|
||||
|
||||
# 命中 AIGC 预处理链路后,页面通常仍停留在发布页,需要再点一次确认发布才真正提交
|
||||
try:
|
||||
saw_aigc_done = any(
|
||||
("querylooptask" in u.lower() and '"done":true' in (b or "").lower())
|
||||
for _, u, _, b in net_events
|
||||
)
|
||||
if saw_aigc_done and not retried_after_aigc:
|
||||
if await publish_btn.count() and not await publish_btn.is_disabled():
|
||||
await publish_btn.click()
|
||||
retried_after_aigc = True
|
||||
alipay_logger.info(_msg("🔁", "检测到 AIGC 预处理完成,已二次点击确认发布"))
|
||||
await page.wait_for_timeout(500)
|
||||
await _collect_click_diag("AIGC后二次点击后")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 核心判据:必须至少看到一次 captcha verify / publishShortVideo 请求
|
||||
saw_captcha_verify = any("captcha.alipay.com/api/v1/captcha/verify" in u.lower() for _, u, _, _ in net_events)
|
||||
saw_publish_submit = any("publishshortvideo.json" in u.lower() for _, u, _, _ in net_events)
|
||||
no_submit_signal = not (saw_captcha_verify or saw_publish_submit)
|
||||
if no_submit_signal and submit_click_retries < 3 and (time.monotonic() - last_click_ts) >= 8:
|
||||
try:
|
||||
if await publish_btn.count() and not await publish_btn.is_disabled():
|
||||
await publish_btn.click()
|
||||
submit_click_retries += 1
|
||||
last_click_ts = time.monotonic()
|
||||
alipay_logger.info(_msg("🔁", f"未检测到提交请求信号,重试点击确认发布({submit_click_retries}/3)"))
|
||||
await page.wait_for_timeout(500)
|
||||
await _collect_click_diag(f"重试点击后#{submit_click_retries}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if await success_toast.count() and await success_toast.is_visible():
|
||||
alipay_logger.success(_msg("🥳", "视频发布成功(toast 命中)"))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if await fail_toast.count() and await fail_toast.is_visible():
|
||||
raise RuntimeError("发布失败(页面返回失败提示)")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
raise RuntimeError(
|
||||
f"发布后未检测到成功信号(90s),当前地址: {page.url}"
|
||||
)
|
||||
finally:
|
||||
page.remove_listener("response", _on_response)
|
||||
page.remove_listener("request", _on_request)
|
||||
if net_tasks:
|
||||
try:
|
||||
await asyncio.wait(net_tasks, timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
if req_events:
|
||||
alipay_logger.info(_msg("🧾", f"提交阶段请求事件 {len(req_events)} 条(最近10条)"))
|
||||
for m, u, b in req_events[-10:]:
|
||||
alipay_logger.info(_msg("🧾", f"REQ {m} {u} | {b}"))
|
||||
if net_events:
|
||||
alipay_logger.info(_msg("🧾", f"提交阶段网络事件 {len(net_events)} 条(最近10条)"))
|
||||
for m, u, s, b in net_events[-10:]:
|
||||
alipay_logger.info(_msg("🧾", f"{m} {s} {u} | {b}"))
|
||||
|
||||
async def upload(self, playwright: Playwright) -> None:
|
||||
alipay_logger.info(_msg("🧍", "先检查 cookie 和视频文件"))
|
||||
await self.validate_upload_args()
|
||||
alipay_logger.info(_msg("🥳", "上传前检查通过"))
|
||||
|
||||
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
|
||||
context = await browser.new_context(storage_state=self.account_file)
|
||||
await context.grant_permissions(["geolocation"])
|
||||
# 注意:不能用 set_init_script(stealth) —— 会阻止支付宝内容创作平台(qiankun 微应用)渲染
|
||||
|
||||
try:
|
||||
page = await context.new_page()
|
||||
await self.open_upload_page(page)
|
||||
alipay_logger.info(_msg("🏃", f"开始上传视频: {self.title}"))
|
||||
|
||||
await self.upload_video_file(page, self.file_path)
|
||||
await self.fill_title_and_tags(page)
|
||||
await self.fill_description(page)
|
||||
await self.upload_thumbnail(page)
|
||||
await self.apply_collection(page)
|
||||
await self.check_ai_label(page)
|
||||
await self.wait_for_upload_complete(page)
|
||||
await self.submit_publish(page)
|
||||
|
||||
await context.storage_state(path=self.account_file)
|
||||
alipay_logger.success(_msg("🥳", "cookie 更新完毕"))
|
||||
finally:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
async def alipay_upload_video(self):
|
||||
async with async_playwright() as playwright:
|
||||
await self.upload(playwright)
|
||||
|
||||
async def main(self):
|
||||
await self.alipay_upload_video()
|
||||
@@ -58,3 +58,4 @@ kuaishou_logger = create_logger('kuaishou', 'logs/kuaishou.log')
|
||||
baijiahao_logger = create_logger('baijiahao', 'logs/baijiahao.log')
|
||||
xiaohongshu_logger = create_logger('xiaohongshu', 'logs/xiaohongshu.log')
|
||||
youtube_logger = create_logger('youtube', 'logs/youtube.log')
|
||||
alipay_logger = create_logger('alipay', 'logs/alipay.log')
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import segno
|
||||
|
||||
|
||||
@@ -35,13 +36,16 @@ def remove_qrcode_file(qrcode_path: Path | None) -> bool:
|
||||
|
||||
|
||||
def decode_qrcode_from_path(qrcode_path: Path) -> str | None:
|
||||
# Windows 下 cv2.imread 不支持中文路径, 改用 np.fromfile + imdecode
|
||||
import numpy as np
|
||||
|
||||
# Windows 下 cv2.imread 对中文路径不稳定,优先走 numpy+imdecode
|
||||
image = None
|
||||
try:
|
||||
image = cv2.imdecode(np.fromfile(str(qrcode_path), dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
data = np.fromfile(str(qrcode_path), dtype=np.uint8)
|
||||
if data.size > 0:
|
||||
image = cv2.imdecode(data, cv2.IMREAD_COLOR)
|
||||
except Exception:
|
||||
return None
|
||||
image = None
|
||||
if image is None:
|
||||
image = cv2.imread(str(qrcode_path))
|
||||
if image is None:
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user