- Rewrite uploader/baijiahao_uploader/main.py: unify on BaseVideoUploader, add QR-code cookie login (headless), robust publish-page flow with title/desc/tags, thumbnail and collection support - sau_cli: add 'baijiahao login/check/upload-video' subcommands - examples: get_baijiahao_cookie_headless.py for headless QR login
598 lines
28 KiB
Python
598 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""百家号(百度百家号)视频上传 + 扫码登录。
|
||
|
||
功能:
|
||
- baijiahao_cookie_gen: headless 扫码登录(百度 passport 二维码)
|
||
- cookie_auth: 验证 cookie 是否有效
|
||
- baijiahao_setup: 统一入口(检查/触发登录)
|
||
- BaiJiaHaoVideo: 视频上传类
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import inspect
|
||
import json as _json
|
||
import os
|
||
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.log import baijiahao_logger
|
||
from utils.login_qrcode import build_login_qrcode_path, decode_qrcode_from_path, print_terminal_qrcode, remove_qrcode_file
|
||
|
||
|
||
BAIJIAHAO_LOGIN_URL = "https://baijiahao.baidu.com/builder/theme/bjh/login"
|
||
BAIJIAHAO_HOME_URL = "https://baijiahao.baidu.com/builder/rc/home"
|
||
BAIJIAHAO_PUBLISH_URL = "https://baijiahao.baidu.com/builder/rc/edit?type=videoV2"
|
||
# 发布成功后跳转到的 URL 前缀
|
||
BAIJIAHAO_SUCCESS_URL_PREFIX = "https://baijiahao.baidu.com/builder/rc/clue"
|
||
|
||
# 百度 passport 二维码图片选择器
|
||
QR_SELECTOR = 'img[src^="https://passport.baidu.com/v2/api/qrcode"]'
|
||
|
||
|
||
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" / "baijiahao_uploader" / path).resolve())
|
||
return str(path.resolve())
|
||
|
||
|
||
async def _grab_qr(page: Page, account_file: str) -> dict:
|
||
"""截取百度 passport 扫码登录二维码。
|
||
|
||
百家号登录页点「登录」后弹出百度统一登录框,其中二维码是 img[src] 指向
|
||
passport.baidu.com 的图片 URL,可以直接下载或截图。
|
||
"""
|
||
qr = page.locator(QR_SELECTOR).first
|
||
await qr.wait_for(state="attached", timeout=60000)
|
||
|
||
qrcode_path = build_login_qrcode_path(account_file)
|
||
qrcode_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 优先直接下载高清图片 URL
|
||
src = await qr.get_attribute("src")
|
||
if src and src.startswith("https://"):
|
||
try:
|
||
resp = await page.context.request.get(src)
|
||
qrcode_path.write_bytes(await resp.body())
|
||
except Exception:
|
||
await qr.screenshot(path=str(qrcode_path))
|
||
else:
|
||
await qr.screenshot(path=str(qrcode_path))
|
||
|
||
qrcode_content = decode_qrcode_from_path(qrcode_path)
|
||
baijiahao_logger.info(_msg("🖼️", f"二维码已保存到: {qrcode_path}"))
|
||
if qrcode_content:
|
||
print_terminal_qrcode(qrcode_content, qrcode_path, "百度APP/手机百度")
|
||
else:
|
||
baijiahao_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码"))
|
||
return {"image_path": str(qrcode_path), "image_data_url": ""}
|
||
|
||
|
||
async def _is_login_completed(page: Page) -> bool:
|
||
"""判断百度登录是否完成:URL 离开 login 页 或 出现 BDUSS cookie。"""
|
||
if "login" in page.url.lower():
|
||
# 还在登录页,检查 cookies
|
||
cookies = await page.context.cookies()
|
||
if any(c.get("name") in ("BDUSS", "STOKEN") for c in cookies):
|
||
return True
|
||
return False
|
||
# 跳走了说明登录成功
|
||
return True
|
||
|
||
|
||
async def baijiahao_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 120, headless: bool = LOCAL_CHROME_HEADLESS):
|
||
"""无头/有头扫码登录百家号,保存 cookie。
|
||
|
||
流程:打开登录页 → 点「登录」按钮弹出百度 passport 登录框 → 截取二维码 → 等待扫码完成 → 保存 storage_state。
|
||
返回标准 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()
|
||
await page.goto(BAIJIAHAO_LOGIN_URL, timeout=60000, wait_until="domcontentloaded")
|
||
await page.wait_for_timeout(4000)
|
||
|
||
# 点击「登录」按钮触发百度 passport 弹窗
|
||
login_btn = page.get_by_text("登录", exact=True).first
|
||
try:
|
||
await login_btn.click(timeout=10000)
|
||
except Exception:
|
||
# 有些情况直接就在登录状态
|
||
pass
|
||
await page.wait_for_timeout(4000)
|
||
|
||
if headless:
|
||
baijiahao_logger.info(_msg("🧍", "无头登录中:二维码已存为图片,请用百度APP扫码"))
|
||
else:
|
||
baijiahao_logger.info(_msg("🧍", "请在打开的浏览器中扫码登录百家号"))
|
||
|
||
# 截取二维码
|
||
qrcode_info = await _grab_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)
|
||
|
||
baijiahao_logger.info(_msg("🧍", "请扫码,正在耐心等待登录完成"))
|
||
|
||
# 轮询等待登录完成
|
||
for _ in range(max_checks):
|
||
if await _is_login_completed(page):
|
||
baijiahao_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)
|
||
baijiahao_logger.success(_msg("🥳", f"cookie 已保存: {account_file}"))
|
||
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):
|
||
baijiahao_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
|
||
if not result["success"]:
|
||
baijiahao_logger.error(_msg("😢", f"登录失败: {result['message']}"))
|
||
await context.close()
|
||
await browser.close()
|
||
return result
|
||
|
||
|
||
async def cookie_auth(account_file):
|
||
"""验证百家号 cookie 是否有效。访问后台首页,检测是否出现登录提示。"""
|
||
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(BAIJIAHAO_HOME_URL, timeout=60000, wait_until="domcontentloaded")
|
||
await page.wait_for_timeout(5000)
|
||
|
||
if await page.get_by_text("注册/登录百家号").count():
|
||
baijiahao_logger.info(_msg("🥹", "cookie 已失效"))
|
||
return False
|
||
else:
|
||
baijiahao_logger.success(_msg("🥳", "cookie 有效"))
|
||
return True
|
||
except Exception as exc:
|
||
baijiahao_logger.warning(_msg("😵", f"cookie 校验出错,按失效处理: {exc}"))
|
||
return False
|
||
finally:
|
||
await browser.close()
|
||
|
||
|
||
async def baijiahao_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS):
|
||
"""统一入口:检查 cookie → 如无效且 handle=True 则触发扫码登录。"""
|
||
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
|
||
baijiahao_logger.info(_msg("🥹", "cookie 文件不存在或已失效,自动打开浏览器请扫码登录"))
|
||
result = await baijiahao_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 BaiJiaHaoVideo(BaseVideoUploader):
|
||
"""百家号视频上传。
|
||
|
||
流程:打开发布页 → 上传视频文件 → 填标题 → 等待上传/转码完成 → 等封面生成 → 点击发布。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
title,
|
||
file_path,
|
||
tags,
|
||
account_file,
|
||
publish_date=0,
|
||
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.publish_date = publish_date
|
||
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("视频标题不能为空")
|
||
if not self.thumbnail_path:
|
||
raise ValueError("百家号视频发布必须提供横版封面图(--thumbnail)")
|
||
self.file_path = str(self.validate_video_file(self.file_path))
|
||
self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
|
||
|
||
async def upload(self, playwright: Playwright) -> None:
|
||
baijiahao_logger.info(_msg("🧍", "先检查 cookie 和视频文件"))
|
||
await self.validate_upload_args()
|
||
baijiahao_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"])
|
||
|
||
try:
|
||
page = await context.new_page()
|
||
await page.goto(BAIJIAHAO_PUBLISH_URL, timeout=120000, wait_until="domcontentloaded")
|
||
baijiahao_logger.info(_msg("🏃", f"开始上传视频: {self.title}"))
|
||
|
||
# 等待发布页加载
|
||
await page.wait_for_timeout(3000)
|
||
|
||
# 1) 上传视频文件
|
||
file_input = page.locator('input[type="file"][accept*="video"], input[type="file"][accept*="mp4"]').first
|
||
if not await file_input.count():
|
||
file_input = page.locator("div[class^='video-main-container'] input[type='file']").first
|
||
if not await file_input.count():
|
||
file_input = page.locator('input[type="file"]').first
|
||
await file_input.wait_for(state="attached", timeout=30000)
|
||
await file_input.set_input_files(self.file_path)
|
||
baijiahao_logger.info(_msg("🏃", f"已选择视频文件: {self.file_path}"))
|
||
|
||
# 2) 等待进入表单页面(contenteditable 标题区出现即表单渲染完毕)
|
||
title_editor = page.locator('div[class*="contentEditable"]').first
|
||
await title_editor.wait_for(state="visible", timeout=180000)
|
||
await page.wait_for_timeout(1000)
|
||
|
||
# 3) 填写标题
|
||
await self._fill_title(page)
|
||
|
||
# 4) 等待视频上传完成
|
||
await self._wait_upload_complete(page)
|
||
|
||
# 5) 上传横版封面(必填)
|
||
await self._upload_thumbnail(page)
|
||
|
||
# 6) 勾选「含AI生成内容」
|
||
await self._check_ai_declaration(page)
|
||
|
||
# 7) 选择合集(如有配置)
|
||
await self._apply_collection(page)
|
||
|
||
# 8) 点击发布
|
||
await self._submit_publish(page)
|
||
|
||
# 保存 cookie
|
||
await context.storage_state(path=self.account_file)
|
||
baijiahao_logger.success(_msg("🥳", "cookie 更新完毕"))
|
||
finally:
|
||
await context.close()
|
||
await browser.close()
|
||
|
||
async def _fill_title(self, page: Page) -> None:
|
||
title_field = page.locator('div[class*="contentEditable"]').first
|
||
await title_field.wait_for(state="visible", timeout=15000)
|
||
title = self.title
|
||
# 百家号标题最少9字
|
||
if len(title) <= 8:
|
||
title += " 你不知道的"
|
||
title = title[: self.max_title_length]
|
||
# 清空原有内容(可能自动填了文件名),再输入标题
|
||
await title_field.click()
|
||
await page.keyboard.press("Control+a")
|
||
await page.keyboard.press("Backspace")
|
||
await title_field.fill(title)
|
||
baijiahao_logger.info(_msg("🏷️", f"标题已填写: {title}"))
|
||
|
||
async def _wait_upload_complete(self, page: Page, timeout: int = 600) -> None:
|
||
"""等待视频真正上传完成。
|
||
|
||
百度真实上传进度是一段百分比文字(9%…99%,上传完成后消失,本文件实测约 35s)。
|
||
旧实现用 'div .cover-overlay:has-text("上传中")' 判断——经实测该元素恒不存在,
|
||
导致选完文件立即误判"上传完毕"(约 4s)。大文件此时其实还在后台上传,随后点
|
||
发布会被百度以"确保视频已经上传完毕"拒绝(产出 0 作品)。改为跟踪百分比进度:
|
||
出现过进度且进度消失/达 100% 才算真正上传完成。
|
||
"""
|
||
import re as _re
|
||
start = time.monotonic()
|
||
seen_progress = False
|
||
gone_count = 0
|
||
while True:
|
||
if time.monotonic() - start > timeout:
|
||
baijiahao_logger.warning(_msg("⚠️", f"等待上传超时(>{timeout}s),继续后续步骤"))
|
||
return
|
||
|
||
body = ""
|
||
try:
|
||
body = await page.inner_text("body")
|
||
except Exception:
|
||
pass
|
||
|
||
if "上传失败" in body:
|
||
raise RuntimeError("视频上传失败")
|
||
|
||
m = _re.search(r'(\d{1,3})\s*%', body)
|
||
pct = int(m.group(1)) if m else None
|
||
|
||
if pct is not None and pct < 100:
|
||
seen_progress = True
|
||
gone_count = 0
|
||
baijiahao_logger.info(_msg("🏃", f"上传中 {pct}%"))
|
||
await asyncio.sleep(2)
|
||
continue
|
||
|
||
if seen_progress:
|
||
# 进度百分比已消失/到 100%,连续两次确认后判为上传完成
|
||
gone_count += 1
|
||
if gone_count >= 2:
|
||
baijiahao_logger.success(_msg("🥳", "视频上传完毕"))
|
||
return
|
||
await asyncio.sleep(2)
|
||
continue
|
||
|
||
# 一直没出现过进度:小文件可能秒传完成;给 15s 窗口后放行
|
||
if time.monotonic() - start > 15:
|
||
baijiahao_logger.success(_msg("🥳", "视频上传完毕"))
|
||
return
|
||
await asyncio.sleep(2)
|
||
|
||
async def _upload_thumbnail(self, page: Page) -> None:
|
||
"""上传横版封面(必填)。
|
||
|
||
流程:点击「选择封面」→ 弹窗中点「上传」按钮 → 设置图片文件 → 等待上传完成 → 确认。
|
||
如果没有提供 thumbnail_path,等待系统自动生成封面即可。
|
||
"""
|
||
if not self.thumbnail_path:
|
||
# 没有自定义封面,等系统自动生成
|
||
await self._wait_cover_ready(page)
|
||
return
|
||
|
||
try:
|
||
# 1) 点击「选择封面」入口
|
||
cover_entry = page.locator('[data-testid="select-cover"]').first
|
||
if not await cover_entry.count():
|
||
# 备选:通过文本定位
|
||
cover_entry = page.get_by_text("选择封面", exact=True).first
|
||
await cover_entry.scroll_into_view_if_needed()
|
||
await cover_entry.click(timeout=10000)
|
||
baijiahao_logger.info(_msg("🏃", "已点击「选择封面」"))
|
||
await page.wait_for_timeout(2000)
|
||
|
||
# 2) 弹窗中找「上传」按钮并点击
|
||
# 百家号封面弹窗通常有「上传」tab/按钮
|
||
upload_btn = page.locator('button:has-text("上传"), div:has-text("上传"):not(:has(*)):visible').first
|
||
if not await upload_btn.count():
|
||
upload_btn = page.get_by_text("上传", exact=True).first
|
||
await upload_btn.click(timeout=8000)
|
||
await page.wait_for_timeout(1500)
|
||
|
||
# 3) 设置图片文件到 file input
|
||
# 弹窗中会出现 input[type=file]
|
||
img_input = page.locator('input[type="file"][accept*="image"], input[type="file"][accept*="jpg"], input[type="file"][accept*="png"]').first
|
||
if not await img_input.count():
|
||
# 通用 fallback:弹窗内最新出现的 file input
|
||
img_input = page.locator('input[type="file"]').last
|
||
await img_input.set_input_files(self.thumbnail_path)
|
||
baijiahao_logger.info(_msg("🏃", f"已选择封面图片: {self.thumbnail_path}"))
|
||
|
||
# 4) 等待并点击确认/完成按钮(如有裁剪弹窗)。
|
||
# 裁剪弹窗渲染有延迟(图片上传+服务端处理),之前用固定 sleep(3s) 后
|
||
# 一次性检查 confirm_btn,弹窗还没渲染出来时会被误判为"无需确认"而跳过点击,
|
||
# 导致封面选择实际未提交,但日志仍打「封面已上传」成功——这是本次线上
|
||
# 百家号视频没有封面、日志却显示成功的根因。改为轮询等待(不放大超时时长本身
|
||
# 不算错误:裁剪弹窗本就是可选的,等不到也可能是流程本身没有该弹窗)。
|
||
confirm_btn = page.locator('button:has-text("确定"), button:has-text("完成"), button:has-text("确认")').first
|
||
confirmed = False
|
||
try:
|
||
await confirm_btn.wait_for(state="visible", timeout=15000)
|
||
await confirm_btn.click(timeout=8000)
|
||
await page.wait_for_timeout(1000)
|
||
confirmed = True
|
||
except PWTimeoutError:
|
||
baijiahao_logger.debug("封面确认按钮未出现,可能本次流程无需裁剪确认")
|
||
|
||
if confirmed:
|
||
baijiahao_logger.success(_msg("🖼️", "封面已上传"))
|
||
else:
|
||
# 没有等到确认按钮:不确定封面是否真正生效,不再冒充成功,
|
||
# 交给下方 except 分支同一套"等待系统自动封面"兜底逻辑核实/兜底。
|
||
raise RuntimeError("封面确认按钮未出现,无法确认封面是否生效")
|
||
except Exception as exc:
|
||
baijiahao_logger.warning(_msg("⚠️", f"封面上传失败: {exc},尝试等待系统自动封面"))
|
||
# fallback:等系统自动生成
|
||
await self._wait_cover_ready(page)
|
||
|
||
async def _check_ai_declaration(self, page: Page) -> None:
|
||
"""选择「含AI生成内容」创作声明。
|
||
|
||
点击「请选择创作声明」input → 弹出 modal 弹窗 → 点选「含AI生成内容」→ 点「确定」。
|
||
"""
|
||
try:
|
||
# 点击创作声明输入框触发弹窗
|
||
trigger = page.locator('input[placeholder="请选择创作声明"]').first
|
||
await trigger.scroll_into_view_if_needed()
|
||
await trigger.click(force=True, timeout=8000)
|
||
await page.wait_for_timeout(3000)
|
||
|
||
# 弹窗内点选「含AI生成内容」
|
||
ai_option = page.locator('.cheetah-modal-wrap :text("含AI生成内容")').first
|
||
if not await ai_option.count():
|
||
ai_option = page.locator('text="含AI生成内容"').first
|
||
await ai_option.wait_for(state="visible", timeout=10000)
|
||
await ai_option.click(timeout=5000)
|
||
await page.wait_for_timeout(1000)
|
||
|
||
# 点「确定」按钮关闭弹窗(弹窗可能在点选后仍存在)
|
||
modal = page.locator('.cheetah-modal-wrap:visible').first
|
||
if await modal.count():
|
||
confirm_btn = modal.locator('button:has-text("确定")').first
|
||
if await confirm_btn.count() and await confirm_btn.is_visible():
|
||
await confirm_btn.click(timeout=5000)
|
||
await page.wait_for_timeout(500)
|
||
else:
|
||
# 确定按钮不可见,尝试 force click 或按 Escape 关闭
|
||
await page.keyboard.press("Escape")
|
||
await page.wait_for_timeout(500)
|
||
|
||
baijiahao_logger.success(_msg("🏷️", "已选择「含AI生成内容」"))
|
||
except Exception as exc:
|
||
# 如果失败,尝试关闭可能残留的弹窗
|
||
try:
|
||
await page.keyboard.press("Escape")
|
||
await page.wait_for_timeout(500)
|
||
except Exception:
|
||
pass
|
||
baijiahao_logger.warning(_msg("⚠️", f"选择 AI 声明失败: {exc}"))
|
||
|
||
async def _apply_collection(self, page: Page) -> None:
|
||
"""选择合集(cheetah-select 下拉搜索框)。
|
||
|
||
placeholder: "选择同主题的合集,可获得更多播放机会"
|
||
有 collection_name 时点开下拉 → 搜索/选中目标合集;没有则跳过。
|
||
"""
|
||
if not self.collection_name:
|
||
return
|
||
try:
|
||
# 定位合集下拉框(通过 placeholder 文案)
|
||
select_box = page.locator('.cheetah-select:has(.cheetah-select-selection-placeholder:has-text("选择同主题的合集"))').first
|
||
if not await select_box.count():
|
||
select_box = page.locator('.cheetah-select-selection-placeholder:has-text("合集")').locator('xpath=ancestor::div[contains(@class,"cheetah-select")]').first
|
||
if not await select_box.count():
|
||
baijiahao_logger.warning(_msg("⚠️", "未找到合集选择器,跳过"))
|
||
return
|
||
|
||
await select_box.scroll_into_view_if_needed()
|
||
await select_box.click(timeout=8000)
|
||
await page.wait_for_timeout(1500)
|
||
|
||
# 在搜索框中输入合集名(触发搜索过滤)
|
||
search_input = select_box.locator('input.cheetah-select-selection-search-input').first
|
||
if await search_input.count():
|
||
await search_input.fill(self.collection_name)
|
||
await page.wait_for_timeout(1500)
|
||
|
||
# 从下拉选项中选中目标合集
|
||
option = page.locator(f'[role="option"]:has-text("{self.collection_name}"), .cheetah-select-item:has-text("{self.collection_name}")').first
|
||
if await option.count():
|
||
await option.click(timeout=5000)
|
||
await page.wait_for_timeout(500)
|
||
baijiahao_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}"))
|
||
else:
|
||
baijiahao_logger.warning(_msg("⚠️", f"账号中无「{self.collection_name}」合集,跳过"))
|
||
await page.keyboard.press("Escape")
|
||
except Exception as exc:
|
||
baijiahao_logger.warning(_msg("⚠️", f"选择合集失败,跳过: {exc}"))
|
||
|
||
async def _wait_cover_ready(self, page: Page, timeout: int = 120) -> None:
|
||
"""等待百家号自动生成封面图。"""
|
||
start = time.monotonic()
|
||
while True:
|
||
if time.monotonic() - start > timeout:
|
||
baijiahao_logger.warning(_msg("⚠️", "等待封面生成超时,继续发布"))
|
||
return
|
||
if await page.locator("div.cheetah-spin-container img").count():
|
||
baijiahao_logger.info(_msg("🖼️", "封面已生成"))
|
||
return
|
||
baijiahao_logger.info(_msg("🏃", "等待封面生成..."))
|
||
await asyncio.sleep(3)
|
||
|
||
async def _submit_publish(self, page: Page) -> None:
|
||
"""点击发布按钮并确认成功。"""
|
||
# 确保没有残留弹窗遮挡
|
||
modal = page.locator('.cheetah-modal-wrap:visible').first
|
||
if await modal.count():
|
||
await page.keyboard.press("Escape")
|
||
await page.wait_for_timeout(1000)
|
||
|
||
# 百家号发布按钮有 data-testid="publish-btn"
|
||
publish_btn = page.locator('[data-testid="publish-btn"]').first
|
||
if not await publish_btn.count():
|
||
publish_btn = page.locator('button:text-is("发布")').first
|
||
if not await publish_btn.count():
|
||
publish_btn = page.locator('button:has-text("发布")').last
|
||
await publish_btn.wait_for(state="visible", timeout=15000)
|
||
await publish_btn.click(force=True)
|
||
baijiahao_logger.info(_msg("🏃", "已点击发布按钮"))
|
||
|
||
# 等待跳转或成功提示(最多30s)
|
||
start = time.monotonic()
|
||
while time.monotonic() - start < 30:
|
||
url = page.url
|
||
# 发布成功跳转
|
||
if BAIJIAHAO_SUCCESS_URL_PREFIX in url or "/rc/content" in url or "/rc/home" in url:
|
||
baijiahao_logger.success(_msg("🥳", "视频发布成功"))
|
||
return
|
||
# 检查是否出现百度安全验证
|
||
if await page.locator('text="百度安全验证"').count():
|
||
raise RuntimeError("出现百度安全验证,需人工处理")
|
||
# 检查是否有错误提示阻止发布
|
||
error_toast = page.locator('.cheetah-message-error, .cheetah-message-warning').first
|
||
if await error_toast.count() and await error_toast.is_visible():
|
||
err_text = await error_toast.inner_text()
|
||
baijiahao_logger.warning(_msg("⚠️", f"发布提示: {err_text}"))
|
||
await page.wait_for_timeout(1000)
|
||
|
||
# 超时后再检查一次
|
||
if BAIJIAHAO_SUCCESS_URL_PREFIX in page.url or "/rc/content" in page.url:
|
||
baijiahao_logger.success(_msg("🥳", "视频发布成功"))
|
||
else:
|
||
raise RuntimeError(f"发布后未跳转到成功页面(30s),当前 URL: {page.url}")
|
||
|
||
async def main(self):
|
||
async with async_playwright() as playwright:
|
||
await self.upload(playwright)
|