`,
+ 选中后对应 `woo-radio-shadow` 追加 `woo-radio-checked`。
+ """
+ label = page.locator('label.woo-radio-main:has(span.woo-radio-text:text-is("二创"))').first
+ await label.wait_for(state="visible", timeout=20000)
+ await label.click()
+ await page.wait_for_timeout(500)
+
+ checked_sel = 'label.woo-radio-main:has(span.woo-radio-text:text-is("二创")) span.woo-radio-checked'
+ if not await page.locator(checked_sel).count():
+ # 兜底:直接勾选 radio input
+ try:
+ await label.locator('input.woo-radio-input').check()
+ await page.wait_for_timeout(300)
+ except Exception:
+ pass
+ if not await page.locator(checked_sel).count():
+ raise RuntimeError("类型「二创」未选中")
+ weibo_logger.info(_msg("🏷️", "类型已选:二创"))
+
+ async def _select_declaration(self, page: Page) -> None:
+ """内容声明(必选):选择「含AI生成内容」。
+
+ 真实 DOM:
+ - 触发下拉:`
` 内 `.woo-pop-ctrl`(带 caretDown 的 wbpro-select)
+ - 弹层:`
`,选项 ``
+ - 选中后该 button 内 `._check_nsgmr_237` 追加 `_checkActive_nsgmr_251`(带 _checkMark)
+ - 底部 `._footer_nsgmr_270 button`("确定")关闭弹层
+ """
+ # 打开下拉
+ trigger = page.locator('div[class*="_gap1_nsgmr"] .woo-pop-ctrl').first
+ if not await trigger.count():
+ trigger = page.locator('div:has(> div[class*="_tit1_nsgmr"]) .woo-pop-ctrl').first
+ await trigger.wait_for(state="visible", timeout=15000)
+ await trigger.click()
+ await page.wait_for_timeout(1000)
+
+ panel = page.locator('div[class*="_panel_nsgmr"]').first
+ if await panel.count():
+ try:
+ await panel.wait_for(state="visible", timeout=8000)
+ except PWTimeoutError:
+ panel = None
+ else:
+ panel = None
+
+ scope = panel if panel is not None else page
+ ai_opt = scope.locator('button:has(span:text-is("含AI生成内容"))').first
+ await ai_opt.wait_for(state="visible", timeout=8000)
+ await ai_opt.click()
+ await page.wait_for_timeout(500)
+
+ # 校验选中态
+ if not await ai_opt.locator('[class*="_checkActive"]').count():
+ weibo_logger.warning(_msg("⚠️", "内容声明「含AI生成内容」疑似未激活,仍尝试点确定"))
+
+ # 点确定关闭弹层
+ confirm = scope.locator('div[class*="_footer_nsgmr"] button:has(span:text-is("确定"))').first
+ if not await confirm.count():
+ confirm = scope.locator('button:has(span:text-is("确定"))').last
+ if await confirm.count():
+ await confirm.click()
+ await page.wait_for_timeout(500)
+ weibo_logger.info(_msg("🏷️", "内容声明已选:含AI生成内容"))
+
+ async def _fill_description(self, page: Page) -> None:
+ """填写描述区域(正文 + 标签)。
+
+ 微博描述区 placeholder: "有什么新鲜事想分享给大家?"
+ 标签用 #话题# 格式插入到描述末尾。
+ """
+ desc_field = page.get_by_placeholder("有什么新鲜事想分享给大家?")
+ if not await desc_field.count():
+ weibo_logger.warning(_msg("⚠️", "未找到描述输入框"))
+ return
+
+ # 组装描述内容:正文 + 标签
+ content = self.desc
+ if self.tags:
+ tag_str = " ".join(f"#{t}#" for t in self.tags)
+ content = f"{content}\n{tag_str}" if content else tag_str
+
+ if content:
+ await desc_field.click()
+ await desc_field.fill(content)
+ weibo_logger.info(_msg("📝", f"描述已填写({len(content)}字)"))
+
+ async def _apply_collection(self, page: Page) -> None:
+ """合集:选已有,没有则新建。
+
+ 真实 DOM:打开「合集」开关后出现合集面板 `._scroll_19x8d_143`——已有合集每行一个
+ `woo-checkbox` + 只读 `input value="名字(共N集)"`;末尾 `._add_19x8d_63`(「新建合集」)。
+ - 已有:勾选名字匹配(去掉"(共N集)"后缀后)那一行的 checkbox。
+ - 没有:点「新建合集」→ 新增一行(自动勾选)且带可编辑 input → 填合集名(≤12)。
+ """
+ target = (self.collection_name or "").strip()
+ if not target:
+ return
+
+ # 1) 打开合集开关
+ block = page.locator('div[class*="_switch_"]:has(div[class*="_tit1_"]:text-is("合集"))').first
+ if not await block.count():
+ block = page.locator('div:has(> div:text-is("合集")):has(label.woo-switch-main)').first
+ try:
+ switch_input = block.locator('label.woo-switch-main input.woo-switch-input').first
+ try:
+ already = await switch_input.is_checked()
+ except Exception:
+ already = False
+ if not already:
+ for sw in (
+ block.locator('label.woo-switch-main span[role="switch"]').first,
+ block.locator('label.woo-switch-main').first,
+ ):
+ try:
+ await sw.click(timeout=6000)
+ except Exception:
+ try:
+ await sw.click(timeout=4000, force=True)
+ except Exception:
+ continue
+ await page.wait_for_timeout(1000)
+ try:
+ if await switch_input.is_checked():
+ break
+ except Exception:
+ break
+ except Exception as exc:
+ weibo_logger.warning(_msg("⚠️", f"打开合集开关异常,仍尝试找面板: {exc}"))
+
+ # 2) 合集面板
+ panel = page.locator('div[class*="_scroll_"]:has(div[class*="_add_"])').first
+ if not await panel.count():
+ panel = page.locator('div:has(> div[class*="_add_"]:has-text("新建合集"))').first
+ try:
+ await panel.wait_for(state="visible", timeout=8000)
+ except PWTimeoutError:
+ weibo_logger.warning(_msg("⚠️", "未见合集面板,跳过合集"))
+ return
+
+ # 3) 匹配已有合集(去掉"(共N集)"后缀)
+ rows = panel.locator('div[class*="_top2_"]')
+ n = await rows.count()
+ matched = False
+ for i in range(n):
+ row = rows.nth(i)
+ inp = row.locator('input[type="text"]').first
+ if not await inp.count():
+ continue
+ val = (await inp.get_attribute("value")) or ""
+ name = re.sub(r"\(共\d+集\)\s*$", "", val).strip()
+ if name and name == target:
+ await row.locator('label.woo-checkbox-main').first.click()
+ await page.wait_for_timeout(400)
+ matched = True
+ weibo_logger.info(_msg("🥳", f"已选已有合集:{target}"))
+ break
+
+ # 4) 没有则新建(best-effort:新建失败只跳过合集,绝不中断发布)
+ if not matched:
+ try:
+ add_btn = panel.locator('div[class*="_add_"]:has-text("新建合集")').first
+ if not await add_btn.count():
+ add_btn = page.locator('div:has-text("新建合集")').last
+ # 「新建合集」整行 598px 宽、可点的"+新建合集"文字在左侧;点整行几何中心会落到
+ # 右侧空白、不触发。改为点内部"新建合集"文字 span(在左侧、必命中 onClick)。
+ add_target = add_btn.get_by_text("新建合集", exact=True).first
+ if not await add_target.count():
+ add_target = add_btn
+ # 新建行的可编辑 input(已有行的 input 都带 disabled,新建行的没有)
+ new_inp = panel.locator('div[class*="_top2_"] input[type="text"]:not([disabled])').last
+ created = False
+ for _ in range(3):
+ try:
+ await add_target.scroll_into_view_if_needed(timeout=2000)
+ except Exception:
+ pass
+ try:
+ await add_target.click(timeout=4000)
+ except Exception:
+ try:
+ await add_target.click(timeout=3000, force=True)
+ except Exception:
+ try:
+ await add_target.evaluate("el => el.click()")
+ except Exception:
+ pass
+ await page.wait_for_timeout(800)
+ if await new_inp.count() and await new_inp.is_visible():
+ created = True
+ break
+ if not created:
+ weibo_logger.warning(_msg("⚠️", f"「新建合集」未出现输入行,跳过合集继续发布:{target[:12]}"))
+ return
+ await new_inp.click()
+ await new_inp.fill(target[:12])
+ await page.wait_for_timeout(500)
+ weibo_logger.info(_msg("🥳", f"已新建合集:{target[:12]}"))
+ except Exception as exc:
+ weibo_logger.warning(_msg("⚠️", f"新建合集失败,跳过合集继续发布:{exc}"))
+ return
+
+ async def _submit_publish(self, page: Page) -> None:
+ """点击发布并校验真成功。
+
+ 真实 DOM:
+ - 发布按钮:`._check_2z30i_81 button`(内容"发布")。按钮中心可能被空 div 覆盖,
+ 用 JS 触发按钮自身 click 绕过遮罩。
+ - 成功唯一可靠判据:隐藏成功层 `_layer1_9a8j7_2` 由 `display:none` 变**可见**,
+ 其中含"再发一条视频"按钮 → 用它/该按钮可见判定真成功。
+ ("视频已上传成功,将在转码后发布"文字是恒存在的隐藏模板,不能作判据。)
+ - 60s 内判不到成功 → 抛错(不再冒充成功),交由上层记失败。
+ """
+ # 关掉可能残留的下拉/弹层
+ try:
+ await page.keyboard.press("Escape")
+ await page.wait_for_timeout(300)
+ except Exception:
+ pass
+
+ publish_btn = page.locator('div[class*="_check_2z30i"] button:has(span:text-is("发布"))').first
+ if not await publish_btn.count():
+ publish_btn = page.get_by_role("button", name="发布").first
+ await publish_btn.wait_for(state="visible", timeout=15000)
+ await publish_btn.evaluate("el => el.click()")
+ weibo_logger.info(_msg("🏃", "已点击发布按钮(JS)"))
+
+ success_layer = page.locator('div[class*="_layer1_9a8j7"]').first
+ again_btn = page.locator('button:has(span:text-is("再发一条视频"))').first
+ start = time.monotonic()
+ while time.monotonic() - start < 60:
+ try:
+ if await again_btn.is_visible():
+ weibo_logger.success(_msg("🥳", "视频发布成功(出现「再发一条视频」)"))
+ return
+ except Exception:
+ pass
+ try:
+ if await success_layer.is_visible():
+ weibo_logger.success(_msg("🥳", "视频发布成功(成功层可见)"))
+ return
+ except Exception:
+ pass
+ # 处理可能的二次确认对话框
+ try:
+ dialog = page.locator('.woo-dialog-main, .woo-modal-wrap, [class*="Dialog"]').first
+ if await dialog.count() and await dialog.is_visible():
+ for name in ("确定", "确认", "继续", "仍然发布", "发布"):
+ cb = dialog.locator(f'button:has(span:text-is("{name}"))').first
+ if await cb.count() and await cb.is_visible():
+ await cb.evaluate("el => el.click()")
+ weibo_logger.info(_msg("🏃", f"已确认对话框:{name}"))
+ break
+ except Exception:
+ pass
+ await page.wait_for_timeout(1500)
+
+ raise RuntimeError("发布后 60s 未见成功层/「再发一条视频」,判定发布未成功(未入库)")
+
+ async def main(self):
+ async with async_playwright() as playwright:
+ await self.upload(playwright)
diff --git a/utils/log.py b/utils/log.py
index 646a97c..6008443 100644
--- a/utils/log.py
+++ b/utils/log.py
@@ -59,3 +59,4 @@ 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')
+weibo_logger = create_logger('weibo', 'logs/weibo.log')