| 1 | # -*- coding: utf-8 -*- |
| 2 | """百家号(百度百家号)视频上传 + 扫码登录。 |
| 3 | |
| 4 | 功能: |
| 5 | - baijiahao_cookie_gen: headless 扫码登录(百度 passport 二维码) |
| 6 | - cookie_auth: 验证 cookie 是否有效 |
| 7 | - baijiahao_setup: 统一入口(检查/触发登录) |
| 8 | - BaiJiaHaoVideo: 视频上传类 |
| 9 | """ |
| 10 | from __future__ import annotations |
| 11 | |
| 12 | import asyncio |
| 13 | import inspect |
| 14 | import json as _json |
| 15 | import os |
| 16 | import time |
| 17 | from pathlib import Path |
| 18 | |
| 19 | from playwright.async_api import Page, Playwright, TimeoutError as PWTimeoutError, async_playwright |
| 20 | |
| 21 | from conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH |
| 22 | from uploader.base_video import BaseVideoUploader |
| 23 | from utils.log import baijiahao_logger |
| 24 | from utils.login_qrcode import build_login_qrcode_path, decode_qrcode_from_path, print_terminal_qrcode, remove_qrcode_file |
| 25 | |
| 26 | |
| 27 | BAIJIAHAO_LOGIN_URL = "https://baijiahao.baidu.com/builder/theme/bjh/login" |
| 28 | BAIJIAHAO_HOME_URL = "https://baijiahao.baidu.com/builder/rc/home" |
| 29 | BAIJIAHAO_PUBLISH_URL = "https://baijiahao.baidu.com/builder/rc/edit?type=videoV2" |
| 30 | # 发布成功后跳转到的 URL 前缀 |
| 31 | BAIJIAHAO_SUCCESS_URL_PREFIX = "https://baijiahao.baidu.com/builder/rc/clue" |
| 32 | |
| 33 | # 百度 passport 二维码图片选择器 |
| 34 | QR_SELECTOR = 'img[src^="https://passport.baidu.com/v2/api/qrcode"]' |
| 35 | |
| 36 | |
| 37 | def _msg(emoji: str, text: str) -> str: |
| 38 | return f"{emoji} {text}" |
| 39 | |
| 40 | |
| 41 | def _build_login_result(success: bool, status: str, message: str, account_file: str, qrcode: dict | None = None, current_url: str = "") -> dict: |
| 42 | return { |
| 43 | "success": success, |
| 44 | "status": status, |
| 45 | "message": message, |
| 46 | "account_file": str(account_file), |
| 47 | "qrcode": qrcode, |
| 48 | "current_url": current_url, |
| 49 | } |
| 50 | |
| 51 | |
| 52 | async def _emit_qrcode_callback(qrcode_callback, payload: dict): |
| 53 | if not qrcode_callback: |
| 54 | return |
| 55 | callback_result = qrcode_callback(payload) |
| 56 | if inspect.isawaitable(callback_result): |
| 57 | await callback_result |
| 58 | |
| 59 | |
| 60 | def _build_launch_kwargs(headless: bool) -> dict: |
| 61 | launch_kwargs = {"headless": headless} |
| 62 | if LOCAL_CHROME_PATH: |
| 63 | launch_kwargs["executable_path"] = LOCAL_CHROME_PATH |
| 64 | return launch_kwargs |
| 65 | |
| 66 | |
| 67 | def _resolve_account_file(account_file: str | Path) -> str: |
| 68 | path = Path(account_file).expanduser() |
| 69 | if path.is_absolute(): |
| 70 | return str(path) |
| 71 | if len(path.parts) == 1: |
| 72 | return str((Path(BASE_DIR) / "cookies" / "baijiahao_uploader" / path).resolve()) |
| 73 | return str(path.resolve()) |
| 74 | |
| 75 | |
| 76 | async def _grab_qr(page: Page, account_file: str) -> dict: |
| 77 | """截取百度 passport 扫码登录二维码。 |
| 78 | |
| 79 | 百家号登录页点「登录」后弹出百度统一登录框,其中二维码是 img[src] 指向 |
| 80 | passport.baidu.com 的图片 URL,可以直接下载或截图。 |
| 81 | """ |
| 82 | qr = page.locator(QR_SELECTOR).first |
| 83 | await qr.wait_for(state="attached", timeout=60000) |
| 84 | |
| 85 | qrcode_path = build_login_qrcode_path(account_file) |
| 86 | qrcode_path.parent.mkdir(parents=True, exist_ok=True) |
| 87 | |
| 88 | # 优先直接下载高清图片 URL |
| 89 | src = await qr.get_attribute("src") |
| 90 | if src and src.startswith("https://"): |
| 91 | try: |
| 92 | resp = await page.context.request.get(src) |
| 93 | qrcode_path.write_bytes(await resp.body()) |
| 94 | except Exception: |
| 95 | await qr.screenshot(path=str(qrcode_path)) |
| 96 | else: |
| 97 | await qr.screenshot(path=str(qrcode_path)) |
| 98 | |
| 99 | qrcode_content = decode_qrcode_from_path(qrcode_path) |
| 100 | baijiahao_logger.info(_msg("🖼️", f"二维码已保存到: {qrcode_path}")) |
| 101 | if qrcode_content: |
| 102 | print_terminal_qrcode(qrcode_content, qrcode_path, "百度APP/手机百度") |
| 103 | else: |
| 104 | baijiahao_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码")) |
| 105 | return {"image_path": str(qrcode_path), "image_data_url": ""} |
| 106 | |
| 107 | |
| 108 | async def _is_login_completed(page: Page) -> bool: |
| 109 | """判断百度登录是否完成:URL 离开 login 页 或 出现 BDUSS cookie。""" |
| 110 | if "login" in page.url.lower(): |
| 111 | # 还在登录页,检查 cookies |
| 112 | cookies = await page.context.cookies() |
| 113 | if any(c.get("name") in ("BDUSS", "STOKEN") for c in cookies): |
| 114 | return True |
| 115 | return False |
| 116 | # 跳走了说明登录成功 |
| 117 | return True |
| 118 | |
| 119 | |
| 120 | async def baijiahao_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 120, headless: bool = LOCAL_CHROME_HEADLESS): |
| 121 | """无头/有头扫码登录百家号,保存 cookie。 |
| 122 | |
| 123 | 流程:打开登录页 → 点「登录」按钮弹出百度 passport 登录框 → 截取二维码 → 等待扫码完成 → 保存 storage_state。 |
| 124 | 返回标准 login result dict。 |
| 125 | """ |
| 126 | account_file = _resolve_account_file(account_file) |
| 127 | Path(account_file).parent.mkdir(parents=True, exist_ok=True) |
| 128 | qrcode_path = None |
| 129 | result = _build_login_result(False, "failed", "百家号登录失败", account_file) |
| 130 | |
| 131 | async with async_playwright() as playwright: |
| 132 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless)) |
| 133 | context = await browser.new_context() |
| 134 | try: |
| 135 | page = await context.new_page() |
| 136 | await page.goto(BAIJIAHAO_LOGIN_URL, timeout=60000, wait_until="domcontentloaded") |
| 137 | await page.wait_for_timeout(4000) |
| 138 | |
| 139 | # 点击「登录」按钮触发百度 passport 弹窗 |
| 140 | login_btn = page.get_by_text("登录", exact=True).first |
| 141 | try: |
| 142 | await login_btn.click(timeout=10000) |
| 143 | except Exception: |
| 144 | # 有些情况直接就在登录状态 |
| 145 | pass |
| 146 | await page.wait_for_timeout(4000) |
| 147 | |
| 148 | if headless: |
| 149 | baijiahao_logger.info(_msg("🧍", "无头登录中:二维码已存为图片,请用百度APP扫码")) |
| 150 | else: |
| 151 | baijiahao_logger.info(_msg("🧍", "请在打开的浏览器中扫码登录百家号")) |
| 152 | |
| 153 | # 截取二维码 |
| 154 | qrcode_info = await _grab_qr(page, account_file) |
| 155 | qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None |
| 156 | await _emit_qrcode_callback(qrcode_callback, qrcode_info) |
| 157 | |
| 158 | baijiahao_logger.info(_msg("🧍", "请扫码,正在耐心等待登录完成")) |
| 159 | |
| 160 | # 轮询等待登录完成 |
| 161 | for _ in range(max_checks): |
| 162 | if await _is_login_completed(page): |
| 163 | baijiahao_logger.info(_msg("🥳", f"扫码成功,当前页面: {page.url}")) |
| 164 | result = _build_login_result(True, "success", "百家号扫码登录成功", account_file, qrcode_info, page.url) |
| 165 | break |
| 166 | await page.wait_for_timeout(poll_interval * 1000) |
| 167 | else: |
| 168 | result = _build_login_result(False, "timeout", "等待百家号扫码登录超时", account_file, qrcode_info, page.url) |
| 169 | |
| 170 | if result["success"]: |
| 171 | await asyncio.sleep(2) |
| 172 | await context.storage_state(path=account_file) |
| 173 | baijiahao_logger.success(_msg("🥳", f"cookie 已保存: {account_file}")) |
| 174 | except Exception as exc: |
| 175 | result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "") |
| 176 | finally: |
| 177 | if remove_qrcode_file(qrcode_path): |
| 178 | baijiahao_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}")) |
| 179 | if not result["success"]: |
| 180 | baijiahao_logger.error(_msg("😢", f"登录失败: {result['message']}")) |
| 181 | await context.close() |
| 182 | await browser.close() |
| 183 | return result |
| 184 | |
| 185 | |
| 186 | async def cookie_auth(account_file): |
| 187 | """验证百家号 cookie 是否有效。访问后台首页,检测是否出现登录提示。""" |
| 188 | account_file = _resolve_account_file(account_file) |
| 189 | async with async_playwright() as playwright: |
| 190 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True)) |
| 191 | try: |
| 192 | context = await browser.new_context(storage_state=account_file) |
| 193 | page = await context.new_page() |
| 194 | await page.goto(BAIJIAHAO_HOME_URL, timeout=60000, wait_until="domcontentloaded") |
| 195 | await page.wait_for_timeout(5000) |
| 196 | |
| 197 | if await page.get_by_text("注册/登录百家号").count(): |
| 198 | baijiahao_logger.info(_msg("🥹", "cookie 已失效")) |
| 199 | return False |
| 200 | else: |
| 201 | baijiahao_logger.success(_msg("🥳", "cookie 有效")) |
| 202 | return True |
| 203 | except Exception as exc: |
| 204 | baijiahao_logger.warning(_msg("😵", f"cookie 校验出错,按失效处理: {exc}")) |
| 205 | return False |
| 206 | finally: |
| 207 | await browser.close() |
| 208 | |
| 209 | |
| 210 | async def baijiahao_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS): |
| 211 | """统一入口:检查 cookie → 如无效且 handle=True 则触发扫码登录。""" |
| 212 | account_file = _resolve_account_file(account_file) |
| 213 | if not os.path.exists(account_file) or not await cookie_auth(account_file): |
| 214 | if not handle: |
| 215 | result = _build_login_result(False, "cookie_invalid", "cookie 文件不存在或已失效", account_file) |
| 216 | return result if return_detail else False |
| 217 | baijiahao_logger.info(_msg("🥹", "cookie 文件不存在或已失效,自动打开浏览器请扫码登录")) |
| 218 | result = await baijiahao_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless) |
| 219 | return result if return_detail else result["success"] |
| 220 | |
| 221 | result = _build_login_result(True, "cookie_valid", "cookie 有效", account_file) |
| 222 | return result if return_detail else True |
| 223 | |
| 224 | |
| 225 | class BaiJiaHaoVideo(BaseVideoUploader): |
| 226 | """百家号视频上传。 |
| 227 | |
| 228 | 流程:打开发布页 → 上传视频文件 → 填标题 → 等待上传/转码完成 → 等封面生成 → 点击发布。 |
| 229 | """ |
| 230 | |
| 231 | def __init__( |
| 232 | self, |
| 233 | title, |
| 234 | file_path, |
| 235 | tags, |
| 236 | account_file, |
| 237 | publish_date=0, |
| 238 | desc: str | None = None, |
| 239 | thumbnail_path: str | None = None, |
| 240 | collection_name: str | None = None, |
| 241 | debug: bool = True, |
| 242 | headless: bool = LOCAL_CHROME_HEADLESS, |
| 243 | ): |
| 244 | self.title = title |
| 245 | self.file_path = file_path |
| 246 | self.tags = tags or [] |
| 247 | self.account_file = _resolve_account_file(account_file) |
| 248 | self.publish_date = publish_date |
| 249 | self.desc = desc or "" |
| 250 | self.thumbnail_path = thumbnail_path |
| 251 | self.collection_name = collection_name |
| 252 | self.debug = debug |
| 253 | self.headless = headless |
| 254 | self.local_executable_path = LOCAL_CHROME_PATH |
| 255 | self.max_title_length = 30 |
| 256 | |
| 257 | async def validate_upload_args(self): |
| 258 | if not os.path.exists(self.account_file): |
| 259 | raise RuntimeError(f"cookie文件不存在,请先完成百家号登录: {self.account_file}") |
| 260 | if not await cookie_auth(self.account_file): |
| 261 | raise RuntimeError(f"cookie文件已失效,请先完成百家号登录: {self.account_file}") |
| 262 | if not self.title or not str(self.title).strip(): |
| 263 | raise ValueError("视频标题不能为空") |
| 264 | if not self.thumbnail_path: |
| 265 | raise ValueError("百家号视频发布必须提供横版封面图(--thumbnail)") |
| 266 | self.file_path = str(self.validate_video_file(self.file_path)) |
| 267 | self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path)) |
| 268 | |
| 269 | async def upload(self, playwright: Playwright) -> None: |
| 270 | baijiahao_logger.info(_msg("🧍", "先检查 cookie 和视频文件")) |
| 271 | await self.validate_upload_args() |
| 272 | baijiahao_logger.info(_msg("🥳", "上传前检查通过")) |
| 273 | |
| 274 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless)) |
| 275 | context = await browser.new_context(storage_state=self.account_file) |
| 276 | await context.grant_permissions(["geolocation"]) |
| 277 | |
| 278 | try: |
| 279 | page = await context.new_page() |
| 280 | await page.goto(BAIJIAHAO_PUBLISH_URL, timeout=120000, wait_until="domcontentloaded") |
| 281 | baijiahao_logger.info(_msg("🏃", f"开始上传视频: {self.title}")) |
| 282 | |
| 283 | # 等待发布页加载 |
| 284 | await page.wait_for_timeout(3000) |
| 285 | |
| 286 | # 1) 上传视频文件 |
| 287 | file_input = page.locator('input[type="file"][accept*="video"], input[type="file"][accept*="mp4"]').first |
| 288 | if not await file_input.count(): |
| 289 | file_input = page.locator("div[class^='video-main-container'] input[type='file']").first |
| 290 | if not await file_input.count(): |
| 291 | file_input = page.locator('input[type="file"]').first |
| 292 | await file_input.wait_for(state="attached", timeout=30000) |
| 293 | await file_input.set_input_files(self.file_path) |
| 294 | baijiahao_logger.info(_msg("🏃", f"已选择视频文件: {self.file_path}")) |
| 295 | |
| 296 | # 2) 等待进入表单页面(contenteditable 标题区出现即表单渲染完毕) |
| 297 | title_editor = page.locator('div[class*="contentEditable"]').first |
| 298 | await title_editor.wait_for(state="visible", timeout=180000) |
| 299 | await page.wait_for_timeout(1000) |
| 300 | |
| 301 | # 3) 填写标题 |
| 302 | await self._fill_title(page) |
| 303 | |
| 304 | # 4) 等待视频上传完成 |
| 305 | await self._wait_upload_complete(page) |
| 306 | |
| 307 | # 5) 上传横版封面(必填) |
| 308 | await self._upload_thumbnail(page) |
| 309 | |
| 310 | # 6) 勾选「含AI生成内容」 |
| 311 | await self._check_ai_declaration(page) |
| 312 | |
| 313 | # 7) 选择合集(如有配置) |
| 314 | await self._apply_collection(page) |
| 315 | |
| 316 | # 8) 点击发布 |
| 317 | await self._submit_publish(page) |
| 318 | |
| 319 | # 保存 cookie |
| 320 | await context.storage_state(path=self.account_file) |
| 321 | baijiahao_logger.success(_msg("🥳", "cookie 更新完毕")) |
| 322 | finally: |
| 323 | await context.close() |
| 324 | await browser.close() |
| 325 | |
| 326 | async def _fill_title(self, page: Page) -> None: |
| 327 | title_field = page.locator('div[class*="contentEditable"]').first |
| 328 | await title_field.wait_for(state="visible", timeout=15000) |
| 329 | title = self.title |
| 330 | # 百家号标题最少9字 |
| 331 | if len(title) <= 8: |
| 332 | title += " 你不知道的" |
| 333 | title = title[: self.max_title_length] |
| 334 | # 清空原有内容(可能自动填了文件名),再输入标题 |
| 335 | await title_field.click() |
| 336 | await page.keyboard.press("Control+a") |
| 337 | await page.keyboard.press("Backspace") |
| 338 | await title_field.fill(title) |
| 339 | baijiahao_logger.info(_msg("🏷️", f"标题已填写: {title}")) |
| 340 | |
| 341 | async def _wait_upload_complete(self, page: Page, timeout: int = 600) -> None: |
| 342 | """等待视频真正上传完成。 |
| 343 | |
| 344 | 百度真实上传进度是一段百分比文字(9%…99%,上传完成后消失,本文件实测约 35s)。 |
| 345 | 旧实现用 'div .cover-overlay:has-text("上传中")' 判断——经实测该元素恒不存在, |
| 346 | 导致选完文件立即误判"上传完毕"(约 4s)。大文件此时其实还在后台上传,随后点 |
| 347 | 发布会被百度以"确保视频已经上传完毕"拒绝(产出 0 作品)。改为跟踪百分比进度: |
| 348 | 出现过进度且进度消失/达 100% 才算真正上传完成。 |
| 349 | """ |
| 350 | import re as _re |
| 351 | start = time.monotonic() |
| 352 | seen_progress = False |
| 353 | gone_count = 0 |
| 354 | while True: |
| 355 | if time.monotonic() - start > timeout: |
| 356 | baijiahao_logger.warning(_msg("⚠️", f"等待上传超时(>{timeout}s),继续后续步骤")) |
| 357 | return |
| 358 | |
| 359 | body = "" |
| 360 | try: |
| 361 | body = await page.inner_text("body") |
| 362 | except Exception: |
| 363 | pass |
| 364 | |
| 365 | if "上传失败" in body: |
| 366 | raise RuntimeError("视频上传失败") |
| 367 | |
| 368 | m = _re.search(r'(\d{1,3})\s*%', body) |
| 369 | pct = int(m.group(1)) if m else None |
| 370 | |
| 371 | if pct is not None and pct < 100: |
| 372 | seen_progress = True |
| 373 | gone_count = 0 |
| 374 | baijiahao_logger.info(_msg("🏃", f"上传中 {pct}%")) |
| 375 | await asyncio.sleep(2) |
| 376 | continue |
| 377 | |
| 378 | if seen_progress: |
| 379 | # 进度百分比已消失/到 100%,连续两次确认后判为上传完成 |
| 380 | gone_count += 1 |
| 381 | if gone_count >= 2: |
| 382 | baijiahao_logger.success(_msg("🥳", "视频上传完毕")) |
| 383 | return |
| 384 | await asyncio.sleep(2) |
| 385 | continue |
| 386 | |
| 387 | # 一直没出现过进度:小文件可能秒传完成;给 15s 窗口后放行 |
| 388 | if time.monotonic() - start > 15: |
| 389 | baijiahao_logger.success(_msg("🥳", "视频上传完毕")) |
| 390 | return |
| 391 | await asyncio.sleep(2) |
| 392 | |
| 393 | async def _upload_thumbnail(self, page: Page) -> None: |
| 394 | """上传横版封面(必填)。 |
| 395 | |
| 396 | 流程:点击「选择封面」→ 弹窗中点「上传」按钮 → 设置图片文件 → 等待上传完成 → 确认。 |
| 397 | 如果没有提供 thumbnail_path,等待系统自动生成封面即可。 |
| 398 | """ |
| 399 | if not self.thumbnail_path: |
| 400 | # 没有自定义封面,等系统自动生成 |
| 401 | await self._wait_cover_ready(page) |
| 402 | return |
| 403 | |
| 404 | try: |
| 405 | # 1) 点击「选择封面」入口 |
| 406 | cover_entry = page.locator('[data-testid="select-cover"]').first |
| 407 | if not await cover_entry.count(): |
| 408 | # 备选:通过文本定位 |
| 409 | cover_entry = page.get_by_text("选择封面", exact=True).first |
| 410 | await cover_entry.scroll_into_view_if_needed() |
| 411 | await cover_entry.click(timeout=10000) |
| 412 | baijiahao_logger.info(_msg("🏃", "已点击「选择封面」")) |
| 413 | await page.wait_for_timeout(2000) |
| 414 | |
| 415 | # 2) 弹窗中找「上传」按钮并点击 |
| 416 | # 百家号封面弹窗通常有「上传」tab/按钮 |
| 417 | upload_btn = page.locator('button:has-text("上传"), div:has-text("上传"):not(:has(*)):visible').first |
| 418 | if not await upload_btn.count(): |
| 419 | upload_btn = page.get_by_text("上传", exact=True).first |
| 420 | await upload_btn.click(timeout=8000) |
| 421 | await page.wait_for_timeout(1500) |
| 422 | |
| 423 | # 3) 设置图片文件到 file input |
| 424 | # 弹窗中会出现 input[type=file] |
| 425 | img_input = page.locator('input[type="file"][accept*="image"], input[type="file"][accept*="jpg"], input[type="file"][accept*="png"]').first |
| 426 | if not await img_input.count(): |
| 427 | # 通用 fallback:弹窗内最新出现的 file input |
| 428 | img_input = page.locator('input[type="file"]').last |
| 429 | await img_input.set_input_files(self.thumbnail_path) |
| 430 | baijiahao_logger.info(_msg("🏃", f"已选择封面图片: {self.thumbnail_path}")) |
| 431 | |
| 432 | # 4) 等待并点击确认/完成按钮(如有裁剪弹窗)。 |
| 433 | # 裁剪弹窗渲染有延迟(图片上传+服务端处理),之前用固定 sleep(3s) 后 |
| 434 | # 一次性检查 confirm_btn,弹窗还没渲染出来时会被误判为"无需确认"而跳过点击, |
| 435 | # 导致封面选择实际未提交,但日志仍打「封面已上传」成功——这是本次线上 |
| 436 | # 百家号视频没有封面、日志却显示成功的根因。改为轮询等待(不放大超时时长本身 |
| 437 | # 不算错误:裁剪弹窗本就是可选的,等不到也可能是流程本身没有该弹窗)。 |
| 438 | confirm_btn = page.locator('button:has-text("确定"), button:has-text("完成"), button:has-text("确认")').first |
| 439 | confirmed = False |
| 440 | try: |
| 441 | await confirm_btn.wait_for(state="visible", timeout=15000) |
| 442 | await confirm_btn.click(timeout=8000) |
| 443 | await page.wait_for_timeout(1000) |
| 444 | confirmed = True |
| 445 | except PWTimeoutError: |
| 446 | baijiahao_logger.debug("封面确认按钮未出现,可能本次流程无需裁剪确认") |
| 447 | |
| 448 | if confirmed: |
| 449 | baijiahao_logger.success(_msg("🖼️", "封面已上传")) |
| 450 | else: |
| 451 | # 没有等到确认按钮:不确定封面是否真正生效,不再冒充成功, |
| 452 | # 交给下方 except 分支同一套"等待系统自动封面"兜底逻辑核实/兜底。 |
| 453 | raise RuntimeError("封面确认按钮未出现,无法确认封面是否生效") |
| 454 | except Exception as exc: |
| 455 | baijiahao_logger.warning(_msg("⚠️", f"封面上传失败: {exc},尝试等待系统自动封面")) |
| 456 | # fallback:等系统自动生成 |
| 457 | await self._wait_cover_ready(page) |
| 458 | |
| 459 | async def _check_ai_declaration(self, page: Page) -> None: |
| 460 | """选择「含AI生成内容」创作声明。 |
| 461 | |
| 462 | 点击「请选择创作声明」input → 弹出 modal 弹窗 → 点选「含AI生成内容」→ 点「确定」。 |
| 463 | """ |
| 464 | try: |
| 465 | # 点击创作声明输入框触发弹窗 |
| 466 | trigger = page.locator('input[placeholder="请选择创作声明"]').first |
| 467 | await trigger.scroll_into_view_if_needed() |
| 468 | await trigger.click(force=True, timeout=8000) |
| 469 | await page.wait_for_timeout(3000) |
| 470 | |
| 471 | # 弹窗内点选「含AI生成内容」 |
| 472 | ai_option = page.locator('.cheetah-modal-wrap :text("含AI生成内容")').first |
| 473 | if not await ai_option.count(): |
| 474 | ai_option = page.locator('text="含AI生成内容"').first |
| 475 | await ai_option.wait_for(state="visible", timeout=10000) |
| 476 | await ai_option.click(timeout=5000) |
| 477 | await page.wait_for_timeout(1000) |
| 478 | |
| 479 | # 点「确定」按钮关闭弹窗(弹窗可能在点选后仍存在) |
| 480 | modal = page.locator('.cheetah-modal-wrap:visible').first |
| 481 | if await modal.count(): |
| 482 | confirm_btn = modal.locator('button:has-text("确定")').first |
| 483 | if await confirm_btn.count() and await confirm_btn.is_visible(): |
| 484 | await confirm_btn.click(timeout=5000) |
| 485 | await page.wait_for_timeout(500) |
| 486 | else: |
| 487 | # 确定按钮不可见,尝试 force click 或按 Escape 关闭 |
| 488 | await page.keyboard.press("Escape") |
| 489 | await page.wait_for_timeout(500) |
| 490 | |
| 491 | baijiahao_logger.success(_msg("🏷️", "已选择「含AI生成内容」")) |
| 492 | except Exception as exc: |
| 493 | # 如果失败,尝试关闭可能残留的弹窗 |
| 494 | try: |
| 495 | await page.keyboard.press("Escape") |
| 496 | await page.wait_for_timeout(500) |
| 497 | except Exception: |
| 498 | pass |
| 499 | baijiahao_logger.warning(_msg("⚠️", f"选择 AI 声明失败: {exc}")) |
| 500 | |
| 501 | async def _apply_collection(self, page: Page) -> None: |
| 502 | """选择合集(cheetah-select 下拉搜索框)。 |
| 503 | |
| 504 | placeholder: "选择同主题的合集,可获得更多播放机会" |
| 505 | 有 collection_name 时点开下拉 → 搜索/选中目标合集;没有则跳过。 |
| 506 | """ |
| 507 | if not self.collection_name: |
| 508 | return |
| 509 | try: |
| 510 | # 定位合集下拉框(通过 placeholder 文案) |
| 511 | select_box = page.locator('.cheetah-select:has(.cheetah-select-selection-placeholder:has-text("选择同主题的合集"))').first |
| 512 | if not await select_box.count(): |
| 513 | select_box = page.locator('.cheetah-select-selection-placeholder:has-text("合集")').locator('xpath=ancestor::div[contains(@class,"cheetah-select")]').first |
| 514 | if not await select_box.count(): |
| 515 | baijiahao_logger.warning(_msg("⚠️", "未找到合集选择器,跳过")) |
| 516 | return |
| 517 | |
| 518 | await select_box.scroll_into_view_if_needed() |
| 519 | await select_box.click(timeout=8000) |
| 520 | await page.wait_for_timeout(1500) |
| 521 | |
| 522 | # 在搜索框中输入合集名(触发搜索过滤) |
| 523 | search_input = select_box.locator('input.cheetah-select-selection-search-input').first |
| 524 | if await search_input.count(): |
| 525 | await search_input.fill(self.collection_name) |
| 526 | await page.wait_for_timeout(1500) |
| 527 | |
| 528 | # 从下拉选项中选中目标合集 |
| 529 | option = page.locator(f'[role="option"]:has-text("{self.collection_name}"), .cheetah-select-item:has-text("{self.collection_name}")').first |
| 530 | if await option.count(): |
| 531 | await option.click(timeout=5000) |
| 532 | await page.wait_for_timeout(500) |
| 533 | baijiahao_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}")) |
| 534 | else: |
| 535 | baijiahao_logger.warning(_msg("⚠️", f"账号中无「{self.collection_name}」合集,跳过")) |
| 536 | await page.keyboard.press("Escape") |
| 537 | except Exception as exc: |
| 538 | baijiahao_logger.warning(_msg("⚠️", f"选择合集失败,跳过: {exc}")) |
| 539 | |
| 540 | async def _wait_cover_ready(self, page: Page, timeout: int = 120) -> None: |
| 541 | """等待百家号自动生成封面图。""" |
| 542 | start = time.monotonic() |
| 543 | while True: |
| 544 | if time.monotonic() - start > timeout: |
| 545 | baijiahao_logger.warning(_msg("⚠️", "等待封面生成超时,继续发布")) |
| 546 | return |
| 547 | if await page.locator("div.cheetah-spin-container img").count(): |
| 548 | baijiahao_logger.info(_msg("🖼️", "封面已生成")) |
| 549 | return |
| 550 | baijiahao_logger.info(_msg("🏃", "等待封面生成...")) |
| 551 | await asyncio.sleep(3) |
| 552 | |
| 553 | async def _submit_publish(self, page: Page) -> None: |
| 554 | """点击发布按钮并确认成功。""" |
| 555 | # 确保没有残留弹窗遮挡 |
| 556 | modal = page.locator('.cheetah-modal-wrap:visible').first |
| 557 | if await modal.count(): |
| 558 | await page.keyboard.press("Escape") |
| 559 | await page.wait_for_timeout(1000) |
| 560 | |
| 561 | # 百家号发布按钮有 data-testid="publish-btn" |
| 562 | publish_btn = page.locator('[data-testid="publish-btn"]').first |
| 563 | if not await publish_btn.count(): |
| 564 | publish_btn = page.locator('button:text-is("发布")').first |
| 565 | if not await publish_btn.count(): |
| 566 | publish_btn = page.locator('button:has-text("发布")').last |
| 567 | await publish_btn.wait_for(state="visible", timeout=15000) |
| 568 | await publish_btn.click(force=True) |
| 569 | baijiahao_logger.info(_msg("🏃", "已点击发布按钮")) |
| 570 | |
| 571 | # 等待跳转或成功提示(最多30s) |
| 572 | start = time.monotonic() |
| 573 | while time.monotonic() - start < 30: |
| 574 | url = page.url |
| 575 | # 发布成功跳转 |
| 576 | if BAIJIAHAO_SUCCESS_URL_PREFIX in url or "/rc/content" in url or "/rc/home" in url: |
| 577 | baijiahao_logger.success(_msg("🥳", "视频发布成功")) |
| 578 | return |
| 579 | # 检查是否出现百度安全验证 |
| 580 | if await page.locator('text="百度安全验证"').count(): |
| 581 | raise RuntimeError("出现百度安全验证,需人工处理") |
| 582 | # 检查是否有错误提示阻止发布 |
| 583 | error_toast = page.locator('.cheetah-message-error, .cheetah-message-warning').first |
| 584 | if await error_toast.count() and await error_toast.is_visible(): |
| 585 | err_text = await error_toast.inner_text() |
| 586 | baijiahao_logger.warning(_msg("⚠️", f"发布提示: {err_text}")) |
| 587 | await page.wait_for_timeout(1000) |
| 588 | |
| 589 | # 超时后再检查一次 |
| 590 | if BAIJIAHAO_SUCCESS_URL_PREFIX in page.url or "/rc/content" in page.url: |
| 591 | baijiahao_logger.success(_msg("🥳", "视频发布成功")) |
| 592 | else: |
| 593 | raise RuntimeError(f"发布后未跳转到成功页面(30s),当前 URL: {page.url}") |
| 594 | |
| 595 | async def main(self): |
| 596 | async with async_playwright() as playwright: |
| 597 | await self.upload(playwright) |
| 598 |