| 1 | # -*- coding: utf-8 -*- |
| 2 | """微博视频上传 + 扫码登录。 |
| 3 | |
| 4 | 功能: |
| 5 | - weibo_cookie_gen: headless 扫码登录(微博 passport 二维码) |
| 6 | - cookie_auth: 验证 cookie 是否有效 |
| 7 | - weibo_setup: 统一入口(检查/触发登录) |
| 8 | - WeiBoVideo: 视频上传类 |
| 9 | |
| 10 | 基于 playwright codegen 录制脚本改写。 |
| 11 | 入口页:https://weibo.com/ |
| 12 | 发布页:点击首页「视频」入口弹出新窗口(视频发布页) |
| 13 | """ |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import asyncio |
| 17 | import inspect |
| 18 | import os |
| 19 | import re |
| 20 | import time |
| 21 | from pathlib import Path |
| 22 | |
| 23 | from playwright.async_api import Page, Playwright, TimeoutError as PWTimeoutError, async_playwright |
| 24 | |
| 25 | from conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH |
| 26 | from uploader.base_video import BaseVideoUploader |
| 27 | from utils.log import weibo_logger |
| 28 | from utils.login_qrcode import build_login_qrcode_path, remove_qrcode_file |
| 29 | |
| 30 | |
| 31 | WEIBO_HOME_URL = "https://weibo.com/" |
| 32 | WEIBO_LOGIN_URL = "https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0&url=https://weibo.com/" |
| 33 | # 微博 passport 扫码登录页(直接跳这里,绕过首页 popup) |
| 34 | WEIBO_PASSPORT_QR_URL = "https://passport.weibo.com/sso/signin?entry=miniblog&source=miniblog&url=https%3A%2F%2Fweibo.com%2F" |
| 35 | |
| 36 | # 微博 passport 二维码选择器(扫码登录页中的二维码图片) |
| 37 | QR_SELECTOR = 'img[src*="qrcode"], img[src*="qr"]' |
| 38 | |
| 39 | |
| 40 | def _msg(emoji: str, text: str) -> str: |
| 41 | return f"{emoji} {text}" |
| 42 | |
| 43 | |
| 44 | def _build_login_result(success: bool, status: str, message: str, account_file: str, qrcode: dict | None = None, current_url: str = "") -> dict: |
| 45 | return { |
| 46 | "success": success, |
| 47 | "status": status, |
| 48 | "message": message, |
| 49 | "account_file": str(account_file), |
| 50 | "qrcode": qrcode, |
| 51 | "current_url": current_url, |
| 52 | } |
| 53 | |
| 54 | |
| 55 | async def _emit_qrcode_callback(qrcode_callback, payload: dict): |
| 56 | if not qrcode_callback: |
| 57 | return |
| 58 | callback_result = qrcode_callback(payload) |
| 59 | if inspect.isawaitable(callback_result): |
| 60 | await callback_result |
| 61 | |
| 62 | |
| 63 | def _build_launch_kwargs(headless: bool) -> dict: |
| 64 | launch_kwargs = {"headless": headless} |
| 65 | if LOCAL_CHROME_PATH: |
| 66 | launch_kwargs["executable_path"] = LOCAL_CHROME_PATH |
| 67 | return launch_kwargs |
| 68 | |
| 69 | |
| 70 | def _resolve_account_file(account_file: str | Path) -> str: |
| 71 | path = Path(account_file).expanduser() |
| 72 | if path.is_absolute(): |
| 73 | return str(path) |
| 74 | if len(path.parts) == 1: |
| 75 | return str((Path(BASE_DIR) / "cookies" / "weibo_uploader" / path).resolve()) |
| 76 | return str(path.resolve()) |
| 77 | |
| 78 | |
| 79 | async def _grab_qr(page: Page, account_file: str) -> dict: |
| 80 | """截取微博 passport 扫码登录二维码。 |
| 81 | |
| 82 | 微博 passport 登录页的二维码可能是 img 或 canvas,尝试多种选择器。 |
| 83 | """ |
| 84 | # 多种可能的二维码选择器(passport 页面结构可能变化) |
| 85 | selectors = [ |
| 86 | 'img[src*="qrcode"]', |
| 87 | 'img[src*="qr"]', |
| 88 | 'img[node-type="qrcode_img"]', |
| 89 | '.qrcode img', |
| 90 | 'canvas', # 部分版本用 canvas 绘制二维码 |
| 91 | ] |
| 92 | |
| 93 | qr = None |
| 94 | for sel in selectors: |
| 95 | loc = page.locator(sel).first |
| 96 | if await loc.count(): |
| 97 | qr = loc |
| 98 | weibo_logger.info(_msg("🔍", f"找到二维码元素: {sel}")) |
| 99 | break |
| 100 | |
| 101 | if not qr: |
| 102 | # 最后兜底:截取整个页面中心区域 |
| 103 | weibo_logger.warning(_msg("⚠️", "未找到二维码元素,截取页面截图")) |
| 104 | qrcode_path = build_login_qrcode_path(account_file) |
| 105 | qrcode_path.parent.mkdir(parents=True, exist_ok=True) |
| 106 | await page.screenshot(path=str(qrcode_path)) |
| 107 | weibo_logger.info(_msg("🖼️", f"页面截图已保存到: {qrcode_path}")) |
| 108 | return {"image_path": str(qrcode_path), "image_data_url": ""} |
| 109 | |
| 110 | await qr.wait_for(state="visible", timeout=30000) |
| 111 | |
| 112 | qrcode_path = build_login_qrcode_path(account_file) |
| 113 | qrcode_path.parent.mkdir(parents=True, exist_ok=True) |
| 114 | |
| 115 | # 优先直接下载高清图片 URL(img 元素) |
| 116 | tag = await qr.evaluate("el => el.tagName.toLowerCase()") |
| 117 | if tag == "img": |
| 118 | src = await qr.get_attribute("src") |
| 119 | if src and src.startswith("http"): |
| 120 | try: |
| 121 | resp = await page.context.request.get(src) |
| 122 | qrcode_path.write_bytes(await resp.body()) |
| 123 | except Exception: |
| 124 | await qr.screenshot(path=str(qrcode_path)) |
| 125 | else: |
| 126 | await qr.screenshot(path=str(qrcode_path)) |
| 127 | else: |
| 128 | # canvas 或其他元素:直接截图 |
| 129 | await qr.screenshot(path=str(qrcode_path)) |
| 130 | |
| 131 | weibo_logger.info(_msg("🖼️", f"二维码已保存到: {qrcode_path}")) |
| 132 | # 终端不渲染二维码,只给出文件位置,用微博APP打开图片扫码 |
| 133 | print(f"请打开 {qrcode_path},用微博APP扫描该二维码登录") |
| 134 | return {"image_path": str(qrcode_path), "image_data_url": ""} |
| 135 | |
| 136 | |
| 137 | async def _is_login_completed(page: Page) -> bool: |
| 138 | """判断微博登录是否完成:URL 回到首页 且 出现用户头像/feed 流。""" |
| 139 | url = page.url |
| 140 | # 还在 login/passport 页面 |
| 141 | if "newlogin" in url or "passport" in url: |
| 142 | return False |
| 143 | # 检查是否回到首页且有用户态 |
| 144 | if "weibo.com" in url and "login" not in url: |
| 145 | # 出现 feed 流或头像说明登录成功 |
| 146 | has_user = await page.locator('[class*="Nav_avatar"], [class*="woo-avatar"]').count() |
| 147 | if has_user: |
| 148 | return True |
| 149 | # cookies 中有 SUB 说明登录成功 |
| 150 | cookies = await page.context.cookies() |
| 151 | if any(c.get("name") == "SUB" for c in cookies): |
| 152 | return True |
| 153 | return False |
| 154 | |
| 155 | |
| 156 | async def weibo_cookie_gen(account_file, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 120, headless: bool = LOCAL_CHROME_HEADLESS): |
| 157 | """无头/有头扫码登录微博,保存 cookie。 |
| 158 | |
| 159 | 流程:直接打开微博 passport 扫码页 → 截取二维码 → 等待扫码完成(跳转回首页)→ 保存 storage_state。 |
| 160 | 返回标准 login result dict。 |
| 161 | """ |
| 162 | account_file = _resolve_account_file(account_file) |
| 163 | Path(account_file).parent.mkdir(parents=True, exist_ok=True) |
| 164 | qrcode_path = None |
| 165 | result = _build_login_result(False, "failed", "微博登录失败", account_file) |
| 166 | |
| 167 | async with async_playwright() as playwright: |
| 168 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless)) |
| 169 | context = await browser.new_context() |
| 170 | try: |
| 171 | page = await context.new_page() |
| 172 | # 直接导航到 passport 扫码登录页,绕过首页的"登录"按钮(headless 下不可见) |
| 173 | await page.goto(WEIBO_PASSPORT_QR_URL, timeout=60000, wait_until="domcontentloaded") |
| 174 | await page.wait_for_timeout(5000) |
| 175 | |
| 176 | if headless: |
| 177 | weibo_logger.info(_msg("🧍", "无头登录中:二维码已存为图片,请用微博APP扫码")) |
| 178 | else: |
| 179 | weibo_logger.info(_msg("🧍", "请在打开的浏览器中扫码登录微博")) |
| 180 | |
| 181 | # 截取二维码 |
| 182 | qrcode_info = await _grab_qr(page, account_file) |
| 183 | qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None |
| 184 | await _emit_qrcode_callback(qrcode_callback, qrcode_info) |
| 185 | |
| 186 | weibo_logger.info(_msg("🧍", "请扫码,正在耐心等待登录完成")) |
| 187 | |
| 188 | # 轮询等待登录完成(页面跳转离开 passport 或出现用户态 cookie) |
| 189 | for _ in range(max_checks): |
| 190 | current_url = page.url |
| 191 | # 跳转离开 passport 页面说明登录成功 |
| 192 | if "passport" not in current_url and "weibo.com" in current_url: |
| 193 | weibo_logger.info(_msg("🥳", f"扫码成功,跳转到: {current_url}")) |
| 194 | result = _build_login_result(True, "success", "微博扫码登录成功", account_file, qrcode_info, current_url) |
| 195 | break |
| 196 | # 检查 cookies 中是否出现 SUB(部分情况页面不跳转但 cookie 已写入) |
| 197 | cookies = await context.cookies() |
| 198 | if any(c.get("name") == "SUB" and c.get("value") for c in cookies): |
| 199 | weibo_logger.info(_msg("🥳", f"扫码成功(检测到 SUB cookie),当前: {current_url}")) |
| 200 | result = _build_login_result(True, "success", "微博扫码登录成功", account_file, qrcode_info, current_url) |
| 201 | break |
| 202 | await page.wait_for_timeout(poll_interval * 1000) |
| 203 | else: |
| 204 | result = _build_login_result(False, "timeout", "等待微博扫码登录超时", account_file, qrcode_info, page.url) |
| 205 | |
| 206 | if result["success"]: |
| 207 | await asyncio.sleep(2) |
| 208 | await context.storage_state(path=account_file) |
| 209 | weibo_logger.success(_msg("🥳", f"cookie 已保存: {account_file}")) |
| 210 | except Exception as exc: |
| 211 | result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "") |
| 212 | finally: |
| 213 | if remove_qrcode_file(qrcode_path): |
| 214 | weibo_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}")) |
| 215 | if not result["success"]: |
| 216 | weibo_logger.error(_msg("😢", f"登录失败: {result['message']}")) |
| 217 | await context.close() |
| 218 | await browser.close() |
| 219 | return result |
| 220 | |
| 221 | |
| 222 | async def cookie_auth(account_file): |
| 223 | """验证微博 cookie 是否有效。访问首页,检测是否出现登录提示。""" |
| 224 | account_file = _resolve_account_file(account_file) |
| 225 | async with async_playwright() as playwright: |
| 226 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True)) |
| 227 | try: |
| 228 | context = await browser.new_context(storage_state=account_file) |
| 229 | page = await context.new_page() |
| 230 | await page.goto(WEIBO_HOME_URL, timeout=60000, wait_until="domcontentloaded") |
| 231 | await page.wait_for_timeout(5000) |
| 232 | |
| 233 | # 检查是否被跳转到登录页 |
| 234 | if "newlogin" in page.url or "passport" in page.url: |
| 235 | weibo_logger.info(_msg("🥹", "cookie 已失效(跳转到登录页)")) |
| 236 | return False |
| 237 | |
| 238 | # 检查是否有「登录」按钮(未登录态会显示) |
| 239 | login_btn = page.get_by_text("登录", exact=True).first |
| 240 | if await login_btn.count() and await login_btn.is_visible(): |
| 241 | weibo_logger.info(_msg("🥹", "cookie 已失效(出现登录按钮)")) |
| 242 | return False |
| 243 | |
| 244 | weibo_logger.success(_msg("🥳", "cookie 有效")) |
| 245 | return True |
| 246 | except Exception as exc: |
| 247 | weibo_logger.warning(_msg("😵", f"cookie 校验出错,按失效处理: {exc}")) |
| 248 | return False |
| 249 | finally: |
| 250 | await browser.close() |
| 251 | |
| 252 | |
| 253 | async def weibo_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS): |
| 254 | """统一入口:检查 cookie → 如无效且 handle=True 则触发扫码登录。""" |
| 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 | weibo_logger.info(_msg("🥹", "cookie 文件不存在或已失效,自动打开浏览器请扫码登录")) |
| 261 | result = await weibo_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 WeiBoVideo(BaseVideoUploader): |
| 269 | """微博视频上传。 |
| 270 | |
| 271 | 流程:打开首页 → 点「视频」入口弹出发布窗口 → 上传视频文件 → |
| 272 | 等待上传完成 → 填标题 → 上传封面 → 勾选二创 + AI声明 → |
| 273 | 填描述 → 点击发布。 |
| 274 | """ |
| 275 | |
| 276 | def __init__( |
| 277 | self, |
| 278 | title, |
| 279 | file_path, |
| 280 | tags, |
| 281 | account_file, |
| 282 | publish_date=0, |
| 283 | desc: str | None = None, |
| 284 | thumbnail_path: str | None = None, |
| 285 | collection_name: str | None = None, |
| 286 | debug: bool = True, |
| 287 | headless: bool = LOCAL_CHROME_HEADLESS, |
| 288 | ): |
| 289 | self.title = title |
| 290 | self.file_path = file_path |
| 291 | self.tags = tags or [] |
| 292 | self.account_file = _resolve_account_file(account_file) |
| 293 | self.publish_date = publish_date |
| 294 | self.desc = desc or "" |
| 295 | self.thumbnail_path = thumbnail_path |
| 296 | self.collection_name = collection_name |
| 297 | self.debug = debug |
| 298 | self.headless = headless |
| 299 | self.local_executable_path = LOCAL_CHROME_PATH |
| 300 | self.max_title_length = 30 |
| 301 | |
| 302 | async def validate_upload_args(self): |
| 303 | if not os.path.exists(self.account_file): |
| 304 | raise RuntimeError(f"cookie文件不存在,请先完成微博登录: {self.account_file}") |
| 305 | if not await cookie_auth(self.account_file): |
| 306 | raise RuntimeError(f"cookie文件已失效,请先完成微博登录: {self.account_file}") |
| 307 | if not self.title or not str(self.title).strip(): |
| 308 | raise ValueError("视频标题不能为空") |
| 309 | if not self.thumbnail_path: |
| 310 | raise ValueError("微博视频发布必须提供封面图(--thumbnail)") |
| 311 | self.file_path = str(self.validate_video_file(self.file_path)) |
| 312 | self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path)) |
| 313 | # 封面文件 < 5MB |
| 314 | thumb_size = Path(self.thumbnail_path).stat().st_size |
| 315 | if thumb_size > 5 * 1024 * 1024: |
| 316 | raise ValueError(f"封面文件过大({thumb_size / 1024 / 1024:.1f}MB),微博要求 < 5MB") |
| 317 | |
| 318 | async def upload(self, playwright: Playwright) -> None: |
| 319 | weibo_logger.info(_msg("🧍", "先检查 cookie 和视频文件")) |
| 320 | await self.validate_upload_args() |
| 321 | weibo_logger.info(_msg("🥳", "上传前检查通过")) |
| 322 | |
| 323 | browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless)) |
| 324 | context = await browser.new_context( |
| 325 | storage_state=self.account_file, |
| 326 | viewport={"width": 1280, "height": 2000}, # 高视口,确保发布按钮等在可视区 |
| 327 | ) |
| 328 | |
| 329 | try: |
| 330 | page = await context.new_page() |
| 331 | await page.goto(WEIBO_HOME_URL, timeout=60000, wait_until="domcontentloaded") |
| 332 | await page.wait_for_timeout(3000) |
| 333 | weibo_logger.info(_msg("🏃", f"开始上传视频: {self.title}")) |
| 334 | |
| 335 | # 1) 点击首页「视频」入口,弹出发布窗口(popup) |
| 336 | publish_page = await self._open_video_publish_page(page) |
| 337 | |
| 338 | # 2) 上传视频文件 |
| 339 | await self._upload_video_file(publish_page) |
| 340 | |
| 341 | # 3) 等待视频真正上传完成("上传完成"块可见) |
| 342 | await self._wait_upload_complete(publish_page) |
| 343 | |
| 344 | # 4) 类型 = 二创(必选) |
| 345 | await self._select_type(publish_page) |
| 346 | |
| 347 | # 5) 内容声明 = 含AI生成内容(必选) |
| 348 | await self._select_declaration(publish_page) |
| 349 | |
| 350 | # 6) 填写标题(必填) |
| 351 | await self._fill_title(publish_page) |
| 352 | |
| 353 | # 7) 上传封面(必填) |
| 354 | await self._upload_thumbnail(publish_page) |
| 355 | |
| 356 | # 8) 合集:选已有,没有则新建(配置了 collection_name 时) |
| 357 | if self.collection_name: |
| 358 | await self._apply_collection(publish_page) |
| 359 | |
| 360 | # 9) 填写描述(包含标签) |
| 361 | await self._fill_description(publish_page) |
| 362 | |
| 363 | # 10) 点击发布并校验真成功 |
| 364 | await self._submit_publish(publish_page) |
| 365 | |
| 366 | # 保存 cookie |
| 367 | await context.storage_state(path=self.account_file) |
| 368 | weibo_logger.success(_msg("🥳", "cookie 更新完毕")) |
| 369 | finally: |
| 370 | await context.close() |
| 371 | await browser.close() |
| 372 | |
| 373 | async def _open_video_publish_page(self, page: Page) -> Page: |
| 374 | """点击首页「视频」入口,等待 popup 视频发布页。""" |
| 375 | async with page.expect_popup(timeout=30000) as popup_info: |
| 376 | # 录制脚本:page.locator("span").filter(has_text="视频").click() |
| 377 | video_btn = page.locator("span").filter(has_text="视频").first |
| 378 | await video_btn.wait_for(state="visible", timeout=15000) |
| 379 | await video_btn.click() |
| 380 | publish_page = await popup_info.value |
| 381 | await publish_page.wait_for_timeout(3000) |
| 382 | weibo_logger.info(_msg("🏃", "已打开视频发布页")) |
| 383 | return publish_page |
| 384 | |
| 385 | async def _upload_video_file(self, page: Page) -> None: |
| 386 | """点击「上传视频」按钮并设置文件。""" |
| 387 | # 录制脚本:page2.get_by_role("button", name="上传视频").click() |
| 388 | upload_btn = page.get_by_role("button", name="上传视频") |
| 389 | await upload_btn.wait_for(state="visible", timeout=15000) |
| 390 | |
| 391 | # 通过 file chooser 设置文件 |
| 392 | async with page.expect_file_chooser(timeout=10000) as fc_info: |
| 393 | await upload_btn.click() |
| 394 | file_chooser = await fc_info.value |
| 395 | await file_chooser.set_files(self.file_path) |
| 396 | weibo_logger.info(_msg("🏃", f"已选择视频文件: {self.file_path}")) |
| 397 | |
| 398 | async def _wait_upload_complete(self, page: Page, timeout: int = 900) -> None: |
| 399 | """等待视频真正上传完成。 |
| 400 | |
| 401 | 真实 DOM:上传区有三个并列的 `_info` 块(上传中 / 暂停中 / 上传完成),未到的 |
| 402 | 状态用 `display:none` 隐藏,只有当前状态那块可见: |
| 403 | - 上传中:`<span>上传中</span>` + `269.61MB/269.61MB` |
| 404 | - 上传完成:`<i class="woo-font woo-font--check">` + `<span>上传完成</span>` |
| 405 | 以"上传完成"块**变为可见**作为唯一完成判据(三块文字都恒在 DOM 里,不能用文字存在与否判断)。 |
| 406 | """ |
| 407 | start = time.monotonic() |
| 408 | done = page.locator('div:has(> i.woo-font--check) span:text-is("上传完成")').first |
| 409 | uploading = page.locator('span:text-is("上传中")').first |
| 410 | last_log = 0.0 |
| 411 | while True: |
| 412 | if time.monotonic() - start > timeout: |
| 413 | raise TimeoutError(f"视频上传超时(>{timeout}s)") |
| 414 | |
| 415 | body = "" |
| 416 | try: |
| 417 | body = await page.inner_text("body") |
| 418 | except Exception: |
| 419 | pass |
| 420 | if "上传失败" in body: |
| 421 | raise RuntimeError("视频上传失败") |
| 422 | |
| 423 | try: |
| 424 | if await done.is_visible(): |
| 425 | weibo_logger.success(_msg("🥳", "视频上传完毕('上传完成' 可见)")) |
| 426 | return |
| 427 | except Exception: |
| 428 | pass |
| 429 | |
| 430 | if time.monotonic() - last_log > 5: |
| 431 | try: |
| 432 | if await uploading.is_visible(): |
| 433 | m = re.search(r"上传中[\s\S]{0,60}?([\d.]+)\s*MB\s*/\s*([\d.]+)\s*MB", body) |
| 434 | if m: |
| 435 | weibo_logger.info(_msg("🏃", f"上传中 {m.group(1)}/{m.group(2)}MB")) |
| 436 | else: |
| 437 | weibo_logger.info(_msg("🏃", "上传中…")) |
| 438 | except Exception: |
| 439 | pass |
| 440 | last_log = time.monotonic() |
| 441 | await asyncio.sleep(2) |
| 442 | |
| 443 | async def _fill_title(self, page: Page) -> None: |
| 444 | """填写标题(最长30字)。""" |
| 445 | title_field = page.get_by_placeholder("填写标题(0~30个字)") |
| 446 | await title_field.wait_for(state="visible", timeout=15000) |
| 447 | title = self.title[:self.max_title_length] |
| 448 | await title_field.click() |
| 449 | await title_field.fill(title) |
| 450 | weibo_logger.info(_msg("🏷️", f"标题已填写: {title}")) |
| 451 | |
| 452 | async def _upload_thumbnail(self, page: Page) -> None: |
| 453 | """上传封面(必填)。 |
| 454 | |
| 455 | 真实 DOM/坑位: |
| 456 | - 主表单 `<a>上传封面</a>` → 弹出「编辑封面」层 `_layer_1mhd8_153`(层内有 |
| 457 | `input[type=file]._file_1mhd8_65`,可直接 set_input_files)。 |
| 458 | - **关键坑**:选图后封面要走**服务端裁切**,期间层内显示"裁切处理中/处理中请稍后…", |
| 459 | "完成"按钮此时点了也不生效;而「编辑封面」层开着时,微博会把**主表单层 |
| 460 | `_layer_19x8d_246` 置为 display:none** → 之后的合集开关/发布按钮全部 0 尺寸点不动。 |
| 461 | - 因此必须:等裁切处理结束(cropper 出 blob 图且无"处理中"字样) → 点"完成" → |
| 462 | **确认编辑封面层已关闭**(否则重试/抛错),主表单才会恢复可见。 |
| 463 | """ |
| 464 | # 打开「上传封面」 |
| 465 | upload_link = page.get_by_role("link", name="上传封面").first |
| 466 | if not await upload_link.count(): |
| 467 | upload_link = page.locator('a:has-text("上传封面")').first |
| 468 | await upload_link.wait_for(state="visible", timeout=20000) |
| 469 | await upload_link.click() |
| 470 | await page.wait_for_timeout(1200) |
| 471 | |
| 472 | # 「编辑封面」层 |
| 473 | cover_layer = page.locator('div.wbpro-layer:has(div:text-is("编辑封面"))').first |
| 474 | await cover_layer.wait_for(state="visible", timeout=15000) |
| 475 | |
| 476 | # 塞封面文件(用 .first 命中可见主输入;.last 会命中隐藏面板里 0 尺寸的裁切器, |
| 477 | # 导致"裁切处理中"永久卡住、发不出 picupload 请求) |
| 478 | file_input = page.locator('input[type="file"][accept*="jpg"]').first |
| 479 | await file_input.wait_for(state="attached", timeout=15000) |
| 480 | await file_input.set_input_files(self.thumbnail_path) |
| 481 | weibo_logger.info(_msg("🏃", f"已选择封面图片: {self.thumbnail_path}")) |
| 482 | |
| 483 | # cropper 本地出图(秒级) |
| 484 | blob_img = page.locator('.cropper-container img[src^="blob:"], .wb_cropper img[src^="blob:"]').first |
| 485 | try: |
| 486 | await blob_img.wait_for(state="attached", timeout=20000) |
| 487 | except PWTimeoutError: |
| 488 | weibo_logger.warning(_msg("⚠️", "cropper 未见 blob 图,仍尝试点完成")) |
| 489 | await page.wait_for_timeout(500) |
| 490 | |
| 491 | # 高容错收尾:裁切时长因图/网络而异,不赌固定时长、不赌某个请求。 |
| 492 | # 只认**真实结果**——「编辑封面」层是否关闭;期间**周期性重复点"完成"** |
| 493 | # (裁切处理中点了无害,处理完的那次点击就会关闭层),并识别裁切/上传报错。 |
| 494 | finish_btn = cover_layer.locator('div.wbpro-layer-btn button:has(span:text-is("完成"))').first |
| 495 | if not await finish_btn.count(): |
| 496 | finish_btn = cover_layer.locator('button:has(span:text-is("完成"))').first |
| 497 | closed = False |
| 498 | last_click = 0.0 |
| 499 | start = time.monotonic() |
| 500 | while time.monotonic() - start < 300: # 宽松 5 分钟 |
| 501 | # 结果判定:编辑封面层不再可见 → 成功 |
| 502 | try: |
| 503 | if not await cover_layer.is_visible(): |
| 504 | closed = True |
| 505 | break |
| 506 | except Exception: |
| 507 | closed = True |
| 508 | break |
| 509 | # 报错识别(裁切/格式/上传失败) |
| 510 | try: |
| 511 | layer_txt = await cover_layer.inner_text() |
| 512 | except Exception: |
| 513 | layer_txt = "" |
| 514 | for err in ("裁切失败", "上传失败", "图片格式", "封面上传失败", "重新上传", "格式不支持"): |
| 515 | if err in layer_txt: |
| 516 | raise RuntimeError(f"封面裁切/上传失败:{err}") |
| 517 | # 周期性点"完成"(每 4s 一次;跳过明确 disabled) |
| 518 | if time.monotonic() - last_click > 4: |
| 519 | try: |
| 520 | if await finish_btn.count() and await finish_btn.is_visible(): |
| 521 | if (await finish_btn.get_attribute("aria-disabled")) != "true": |
| 522 | await finish_btn.click(timeout=3000) |
| 523 | except Exception: |
| 524 | pass |
| 525 | last_click = time.monotonic() |
| 526 | await asyncio.sleep(2) |
| 527 | if not closed: |
| 528 | raise RuntimeError("封面「完成」后编辑封面层长时间(>300s)未关闭,疑似裁切服务异常") |
| 529 | |
| 530 | # 确认主表单层已恢复可见(display 从 none 变回) |
| 531 | main_form = page.locator('div.wbpro-layer[class*="_layer_19x8d"]').first |
| 532 | try: |
| 533 | await main_form.wait_for(state="visible", timeout=10000) |
| 534 | except PWTimeoutError: |
| 535 | weibo_logger.warning(_msg("⚠️", "封面关闭后主表单未确认可见,继续尝试")) |
| 536 | weibo_logger.success(_msg("🖼️", "封面已上传并完成")) |
| 537 | |
| 538 | async def _select_type(self, page: Page) -> None: |
| 539 | """类型(必选):选择「二创」。 |
| 540 | |
| 541 | 真实 DOM:`<div class="_type_1vpmt_29">` 下两个 |
| 542 | `<label class="woo-radio-main"><input type=radio><span class="woo-radio-shadow"><span class="woo-radio-text">二创</span></label>`, |
| 543 | 选中后对应 `woo-radio-shadow` 追加 `woo-radio-checked`。 |
| 544 | """ |
| 545 | label = page.locator('label.woo-radio-main:has(span.woo-radio-text:text-is("二创"))').first |
| 546 | await label.wait_for(state="visible", timeout=20000) |
| 547 | await label.click() |
| 548 | await page.wait_for_timeout(500) |
| 549 | |
| 550 | checked_sel = 'label.woo-radio-main:has(span.woo-radio-text:text-is("二创")) span.woo-radio-checked' |
| 551 | if not await page.locator(checked_sel).count(): |
| 552 | # 兜底:直接勾选 radio input |
| 553 | try: |
| 554 | await label.locator('input.woo-radio-input').check() |
| 555 | await page.wait_for_timeout(300) |
| 556 | except Exception: |
| 557 | pass |
| 558 | if not await page.locator(checked_sel).count(): |
| 559 | raise RuntimeError("类型「二创」未选中") |
| 560 | weibo_logger.info(_msg("🏷️", "类型已选:二创")) |
| 561 | |
| 562 | async def _select_declaration(self, page: Page) -> None: |
| 563 | """内容声明(必选):选择「含AI生成内容」。 |
| 564 | |
| 565 | 真实 DOM: |
| 566 | - 触发下拉:`<div class="_gap1_nsgmr_26">` 内 `.woo-pop-ctrl`(带 caretDown 的 wbpro-select) |
| 567 | - 弹层:`<div class="_panel_nsgmr_114">`,选项 `<button class="_option..."><span class="_optionLabel...">含AI生成内容</span></button>` |
| 568 | - 选中后该 button 内 `._check_nsgmr_237` 追加 `_checkActive_nsgmr_251`(带 _checkMark) |
| 569 | - 底部 `._footer_nsgmr_270 button`("确定")关闭弹层 |
| 570 | """ |
| 571 | # 打开下拉 |
| 572 | trigger = page.locator('div[class*="_gap1_nsgmr"] .woo-pop-ctrl').first |
| 573 | if not await trigger.count(): |
| 574 | trigger = page.locator('div:has(> div[class*="_tit1_nsgmr"]) .woo-pop-ctrl').first |
| 575 | await trigger.wait_for(state="visible", timeout=15000) |
| 576 | await trigger.click() |
| 577 | await page.wait_for_timeout(1000) |
| 578 | |
| 579 | panel = page.locator('div[class*="_panel_nsgmr"]').first |
| 580 | if await panel.count(): |
| 581 | try: |
| 582 | await panel.wait_for(state="visible", timeout=8000) |
| 583 | except PWTimeoutError: |
| 584 | panel = None |
| 585 | else: |
| 586 | panel = None |
| 587 | |
| 588 | scope = panel if panel is not None else page |
| 589 | ai_opt = scope.locator('button:has(span:text-is("含AI生成内容"))').first |
| 590 | await ai_opt.wait_for(state="visible", timeout=8000) |
| 591 | await ai_opt.click() |
| 592 | await page.wait_for_timeout(500) |
| 593 | |
| 594 | # 校验选中态 |
| 595 | if not await ai_opt.locator('[class*="_checkActive"]').count(): |
| 596 | weibo_logger.warning(_msg("⚠️", "内容声明「含AI生成内容」疑似未激活,仍尝试点确定")) |
| 597 | |
| 598 | # 点确定关闭弹层 |
| 599 | confirm = scope.locator('div[class*="_footer_nsgmr"] button:has(span:text-is("确定"))').first |
| 600 | if not await confirm.count(): |
| 601 | confirm = scope.locator('button:has(span:text-is("确定"))').last |
| 602 | if await confirm.count(): |
| 603 | await confirm.click() |
| 604 | await page.wait_for_timeout(500) |
| 605 | weibo_logger.info(_msg("🏷️", "内容声明已选:含AI生成内容")) |
| 606 | |
| 607 | async def _fill_description(self, page: Page) -> None: |
| 608 | """填写描述区域(正文 + 标签)。 |
| 609 | |
| 610 | 微博描述区 placeholder: "有什么新鲜事想分享给大家?" |
| 611 | 标签用 #话题# 格式插入到描述末尾。 |
| 612 | """ |
| 613 | desc_field = page.get_by_placeholder("有什么新鲜事想分享给大家?") |
| 614 | if not await desc_field.count(): |
| 615 | weibo_logger.warning(_msg("⚠️", "未找到描述输入框")) |
| 616 | return |
| 617 | |
| 618 | # 组装描述内容:正文 + 标签 |
| 619 | content = self.desc |
| 620 | if self.tags: |
| 621 | tag_str = " ".join(f"#{t}#" for t in self.tags) |
| 622 | content = f"{content}\n{tag_str}" if content else tag_str |
| 623 | |
| 624 | if content: |
| 625 | await desc_field.click() |
| 626 | await desc_field.fill(content) |
| 627 | weibo_logger.info(_msg("📝", f"描述已填写({len(content)}字)")) |
| 628 | |
| 629 | async def _apply_collection(self, page: Page) -> None: |
| 630 | """合集:选已有,没有则新建。 |
| 631 | |
| 632 | 真实 DOM:打开「合集」开关后出现合集面板 `._scroll_19x8d_143`——已有合集每行一个 |
| 633 | `woo-checkbox` + 只读 `input value="名字(共N集)"`;末尾 `._add_19x8d_63`(「新建合集」)。 |
| 634 | - 已有:勾选名字匹配(去掉"(共N集)"后缀后)那一行的 checkbox。 |
| 635 | - 没有:点「新建合集」→ 新增一行(自动勾选)且带可编辑 input → 填合集名(≤12)。 |
| 636 | """ |
| 637 | target = (self.collection_name or "").strip() |
| 638 | if not target: |
| 639 | return |
| 640 | |
| 641 | # 1) 打开合集开关 |
| 642 | block = page.locator('div[class*="_switch_"]:has(div[class*="_tit1_"]:text-is("合集"))').first |
| 643 | if not await block.count(): |
| 644 | block = page.locator('div:has(> div:text-is("合集")):has(label.woo-switch-main)').first |
| 645 | try: |
| 646 | switch_input = block.locator('label.woo-switch-main input.woo-switch-input').first |
| 647 | try: |
| 648 | already = await switch_input.is_checked() |
| 649 | except Exception: |
| 650 | already = False |
| 651 | if not already: |
| 652 | for sw in ( |
| 653 | block.locator('label.woo-switch-main span[role="switch"]').first, |
| 654 | block.locator('label.woo-switch-main').first, |
| 655 | ): |
| 656 | try: |
| 657 | await sw.click(timeout=6000) |
| 658 | except Exception: |
| 659 | try: |
| 660 | await sw.click(timeout=4000, force=True) |
| 661 | except Exception: |
| 662 | continue |
| 663 | await page.wait_for_timeout(1000) |
| 664 | try: |
| 665 | if await switch_input.is_checked(): |
| 666 | break |
| 667 | except Exception: |
| 668 | break |
| 669 | except Exception as exc: |
| 670 | weibo_logger.warning(_msg("⚠️", f"打开合集开关异常,仍尝试找面板: {exc}")) |
| 671 | |
| 672 | # 2) 合集面板 |
| 673 | panel = page.locator('div[class*="_scroll_"]:has(div[class*="_add_"])').first |
| 674 | if not await panel.count(): |
| 675 | panel = page.locator('div:has(> div[class*="_add_"]:has-text("新建合集"))').first |
| 676 | try: |
| 677 | await panel.wait_for(state="visible", timeout=8000) |
| 678 | except PWTimeoutError: |
| 679 | weibo_logger.warning(_msg("⚠️", "未见合集面板,跳过合集")) |
| 680 | return |
| 681 | |
| 682 | # 3) 匹配已有合集(去掉"(共N集)"后缀) |
| 683 | rows = panel.locator('div[class*="_top2_"]') |
| 684 | n = await rows.count() |
| 685 | matched = False |
| 686 | for i in range(n): |
| 687 | row = rows.nth(i) |
| 688 | inp = row.locator('input[type="text"]').first |
| 689 | if not await inp.count(): |
| 690 | continue |
| 691 | val = (await inp.get_attribute("value")) or "" |
| 692 | name = re.sub(r"\(共\d+集\)\s*$", "", val).strip() |
| 693 | if name and name == target: |
| 694 | await row.locator('label.woo-checkbox-main').first.click() |
| 695 | await page.wait_for_timeout(400) |
| 696 | matched = True |
| 697 | weibo_logger.info(_msg("🥳", f"已选已有合集:{target}")) |
| 698 | break |
| 699 | |
| 700 | # 4) 没有则新建(best-effort:新建失败只跳过合集,绝不中断发布) |
| 701 | if not matched: |
| 702 | try: |
| 703 | add_btn = panel.locator('div[class*="_add_"]:has-text("新建合集")').first |
| 704 | if not await add_btn.count(): |
| 705 | add_btn = page.locator('div:has-text("新建合集")').last |
| 706 | # 「新建合集」整行 598px 宽、可点的"+新建合集"文字在左侧;点整行几何中心会落到 |
| 707 | # 右侧空白、不触发。改为点内部"新建合集"文字 span(在左侧、必命中 onClick)。 |
| 708 | add_target = add_btn.get_by_text("新建合集", exact=True).first |
| 709 | if not await add_target.count(): |
| 710 | add_target = add_btn |
| 711 | # 新建行的可编辑 input(已有行的 input 都带 disabled,新建行的没有) |
| 712 | new_inp = panel.locator('div[class*="_top2_"] input[type="text"]:not([disabled])').last |
| 713 | created = False |
| 714 | for _ in range(3): |
| 715 | try: |
| 716 | await add_target.scroll_into_view_if_needed(timeout=2000) |
| 717 | except Exception: |
| 718 | pass |
| 719 | try: |
| 720 | await add_target.click(timeout=4000) |
| 721 | except Exception: |
| 722 | try: |
| 723 | await add_target.click(timeout=3000, force=True) |
| 724 | except Exception: |
| 725 | try: |
| 726 | await add_target.evaluate("el => el.click()") |
| 727 | except Exception: |
| 728 | pass |
| 729 | await page.wait_for_timeout(800) |
| 730 | if await new_inp.count() and await new_inp.is_visible(): |
| 731 | created = True |
| 732 | break |
| 733 | if not created: |
| 734 | weibo_logger.warning(_msg("⚠️", f"「新建合集」未出现输入行,跳过合集继续发布:{target[:12]}")) |
| 735 | return |
| 736 | await new_inp.click() |
| 737 | await new_inp.fill(target[:12]) |
| 738 | await page.wait_for_timeout(500) |
| 739 | weibo_logger.info(_msg("🥳", f"已新建合集:{target[:12]}")) |
| 740 | except Exception as exc: |
| 741 | weibo_logger.warning(_msg("⚠️", f"新建合集失败,跳过合集继续发布:{exc}")) |
| 742 | return |
| 743 | |
| 744 | async def _submit_publish(self, page: Page) -> None: |
| 745 | """点击发布并校验真成功。 |
| 746 | |
| 747 | 真实 DOM: |
| 748 | - 发布按钮:`._check_2z30i_81 button`(内容"发布")。按钮中心可能被空 div 覆盖, |
| 749 | 用 JS 触发按钮自身 click 绕过遮罩。 |
| 750 | - 成功唯一可靠判据:隐藏成功层 `_layer1_9a8j7_2` 由 `display:none` 变**可见**, |
| 751 | 其中含"再发一条视频"按钮 → 用它/该按钮可见判定真成功。 |
| 752 | ("视频已上传成功,将在转码后发布"文字是恒存在的隐藏模板,不能作判据。) |
| 753 | - 60s 内判不到成功 → 抛错(不再冒充成功),交由上层记失败。 |
| 754 | """ |
| 755 | # 关掉可能残留的下拉/弹层 |
| 756 | try: |
| 757 | await page.keyboard.press("Escape") |
| 758 | await page.wait_for_timeout(300) |
| 759 | except Exception: |
| 760 | pass |
| 761 | |
| 762 | publish_btn = page.locator('div[class*="_check_2z30i"] button:has(span:text-is("发布"))').first |
| 763 | if not await publish_btn.count(): |
| 764 | publish_btn = page.get_by_role("button", name="发布").first |
| 765 | await publish_btn.wait_for(state="visible", timeout=15000) |
| 766 | await publish_btn.evaluate("el => el.click()") |
| 767 | weibo_logger.info(_msg("🏃", "已点击发布按钮(JS)")) |
| 768 | |
| 769 | success_layer = page.locator('div[class*="_layer1_9a8j7"]').first |
| 770 | again_btn = page.locator('button:has(span:text-is("再发一条视频"))').first |
| 771 | start = time.monotonic() |
| 772 | while time.monotonic() - start < 60: |
| 773 | try: |
| 774 | if await again_btn.is_visible(): |
| 775 | weibo_logger.success(_msg("🥳", "视频发布成功(出现「再发一条视频」)")) |
| 776 | return |
| 777 | except Exception: |
| 778 | pass |
| 779 | try: |
| 780 | if await success_layer.is_visible(): |
| 781 | weibo_logger.success(_msg("🥳", "视频发布成功(成功层可见)")) |
| 782 | return |
| 783 | except Exception: |
| 784 | pass |
| 785 | # 处理可能的二次确认对话框 |
| 786 | try: |
| 787 | dialog = page.locator('.woo-dialog-main, .woo-modal-wrap, [class*="Dialog"]').first |
| 788 | if await dialog.count() and await dialog.is_visible(): |
| 789 | for name in ("确定", "确认", "继续", "仍然发布", "发布"): |
| 790 | cb = dialog.locator(f'button:has(span:text-is("{name}"))').first |
| 791 | if await cb.count() and await cb.is_visible(): |
| 792 | await cb.evaluate("el => el.click()") |
| 793 | weibo_logger.info(_msg("🏃", f"已确认对话框:{name}")) |
| 794 | break |
| 795 | except Exception: |
| 796 | pass |
| 797 | await page.wait_for_timeout(1500) |
| 798 | |
| 799 | raise RuntimeError("发布后 60s 未见成功层/「再发一条视频」,判定发布未成功(未入库)") |
| 800 | |
| 801 | async def main(self): |
| 802 | async with async_playwright() as playwright: |
| 803 | await self.upload(playwright) |
| 804 |