From 3a3df419049526a764e1a1ddcea354d18dbbf9ae Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Fri, 23 Jan 2026 10:38:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/qr_login_service.py | 160 ++++++++++-------- .../services/uploader/bilibili_uploader.py | 6 +- .../app/services/uploader/douyin_uploader.py | 2 +- .../services/uploader/xiaohongshu_uploader.py | 2 +- frontend/src/app/publish/page.tsx | 51 ++++-- 5 files changed, 133 insertions(+), 88 deletions(-) diff --git a/backend/app/services/qr_login_service.py b/backend/app/services/qr_login_service.py index 7ccb010..b1ed461 100644 --- a/backend/app/services/qr_login_service.py +++ b/backend/app/services/qr_login_service.py @@ -139,84 +139,72 @@ class QRLoginService: async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]: """ - 提取二维码图片 (借鉴 SuperIPAgent 的方式) + 提取二维码图片 (优化策略顺序) + 根据日志分析:抖音和B站使用 Text 策略成功率最高 """ qr_element = None - # 策略1: 使用 get_by_role (最可靠, SuperIPAgent 使用此方法) - if self.platform == "douyin": - try: - logger.debug(f"[{self.platform}] 策略1(Role): 尝试 get_by_role('img', name='二维码')...") - img = page.get_by_role("img", name="二维码") - await img.wait_for(state="visible", timeout=10000) - if await img.count() > 0: - # 获取 src 属性,如果是 data:image 则直接用,否则截图 - src = await img.get_attribute("src") - if src and src.startswith("data:image"): - logger.info(f"[{self.platform}] 策略1(Role): 获取到 data URI") - # 提取 base64 部分 - return src.split(",")[1] if "," in src else src - else: - logger.info(f"[{self.platform}] 策略1(Role): 截图获取") - screenshot = await img.screenshot() - return base64.b64encode(screenshot).decode() - except Exception as e: - logger.warning(f"[{self.platform}] 策略1(Role) 失败: {e}") - - # 策略2: CSS 选择器 - try: - combined_selector = ", ".join(selectors) - logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...") - el = await page.wait_for_selector(combined_selector, state="visible", timeout=8000) - if el: - logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功") - qr_element = el - except Exception as e: - logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}") - - # 策略3: 基于文本查找附近图片 - if not qr_element: - try: - logger.debug(f"[{self.platform}] 策略3(Text): 开始搜索...") - keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP"] + # 针对抖音和B站:优先使用 Text 策略 (成功率最高,速度最快) + if self.platform in ("douyin", "bilibili"): + # 尝试最多2次 (首次 + 1次重试) + for attempt in range(2): + if attempt > 0: + logger.info(f"[{self.platform}] 等待页面加载后重试...") + await asyncio.sleep(2) - for kw in keywords: + # 策略1: Text (优先,成功率最高) + qr_element = await self._try_text_strategy(page) + if qr_element: try: - text_el = page.get_by_text(kw, exact=False).first - await text_el.wait_for(state="visible", timeout=2000) - - # 向上查找图片 - parent = text_el - for _ in range(5): - parent = parent.locator("..") - imgs = parent.locator("img") - - for i in range(await imgs.count()): - img = imgs.nth(i) - if await img.is_visible(): - bbox = await img.bounding_box() - if bbox and bbox['width'] > 100: - logger.info(f"[{self.platform}] 策略3(Text): 成功") - qr_element = img - break - if qr_element: - break - if qr_element: - break - except Exception: - continue - except Exception as e: - logger.warning(f"[{self.platform}] 策略3(Text) 失败: {e}") - - # 如果找到元素,截图返回 - if qr_element: + screenshot = await qr_element.screenshot() + return base64.b64encode(screenshot).decode() + except Exception as e: + logger.warning(f"[{self.platform}] Text策略截图失败: {e}") + qr_element = None + + # 策略2: CSS (备用) + if not qr_element: + try: + combined_selector = ", ".join(selectors) + logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...") + # 增加超时到5秒,抖音页面加载较慢 + el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000) + if el: + logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功") + screenshot = await el.screenshot() + return base64.b64encode(screenshot).decode() + except Exception as e: + logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}") + + # 如果已成功,退出循环 + if qr_element: + break + else: + # 其他平台 (小红书等):保持原顺序 CSS -> Text + # 策略1: CSS 选择器 try: - screenshot = await qr_element.screenshot() - return base64.b64encode(screenshot).decode() + combined_selector = ", ".join(selectors) + logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...") + el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000) + if el: + logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功") + qr_element = el except Exception as e: - logger.error(f"[{self.platform}] 截图失败: {e}") + logger.warning(f"[{self.platform}] 策略1(CSS) 失败: {e}") + + # 策略2: Text + if not qr_element: + qr_element = await self._try_text_strategy(page) + + # 如果找到元素,截图返回 + if qr_element: + try: + screenshot = await qr_element.screenshot() + return base64.b64encode(screenshot).decode() + except Exception as e: + logger.error(f"[{self.platform}] 截图失败: {e}") - # 所有策略失败 - 不使用全页截图,直接返回 None + # 所有策略失败 logger.error(f"[{self.platform}] 所有QR码提取策略失败") # 保存调试截图 @@ -224,7 +212,37 @@ class QRLoginService: debug_dir.mkdir(exist_ok=True) await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png")) - return None # 不再回退到全页截图 + return None + + async def _try_text_strategy(self, page: Page) -> Optional[Any]: + """基于文本查找二维码图片""" + try: + logger.debug(f"[{self.platform}] 策略Text: 开始搜索...") + keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP", "使用APP扫码"] + + for kw in keywords: + try: + text_el = page.get_by_text(kw, exact=False).first + await text_el.wait_for(state="visible", timeout=2000) + + # 向上查找图片 + parent = text_el + for _ in range(5): + parent = parent.locator("..") + imgs = parent.locator("img") + + for i in range(await imgs.count()): + img = imgs.nth(i) + if await img.is_visible(): + bbox = await img.bounding_box() + if bbox and bbox['width'] > 100: + logger.info(f"[{self.platform}] 策略Text: 成功") + return img + except Exception: + continue + except Exception as e: + logger.warning(f"[{self.platform}] 策略Text 失败: {e}") + return None async def _monitor_login_status(self, page: Page, success_url: str): """监控登录状态""" diff --git a/backend/app/services/uploader/bilibili_uploader.py b/backend/app/services/uploader/bilibili_uploader.py index 5214931..54256e6 100644 --- a/backend/app/services/uploader/bilibili_uploader.py +++ b/backend/app/services/uploader/bilibili_uploader.py @@ -136,14 +136,14 @@ class BilibiliUploader(BaseUploader): logger.success(f"[B站] 上传成功: {bvid}") return { "success": True, - "message": "上传成功" if data.dtime == 0 else "已设置定时发布", + "message": "发布成功,待审核" if data.dtime == 0 else "已设置定时发布", "url": f"https://www.bilibili.com/video/{bvid}" } elif aid: logger.success(f"[B站] 上传成功: av{aid}") return { "success": True, - "message": "上传成功" if data.dtime == 0 else "已设置定时发布", + "message": "发布成功,待审核" if data.dtime == 0 else "已设置定时发布", "url": f"https://www.bilibili.com/video/av{aid}" } else: @@ -151,7 +151,7 @@ class BilibiliUploader(BaseUploader): logger.warning(f"[B站] 上传返回code=0但无bvid/aid: {ret}") return { "success": True, - "message": "上传成功(无法获取视频链接)", + "message": "发布成功,待审核", "url": None } else: diff --git a/backend/app/services/uploader/douyin_uploader.py b/backend/app/services/uploader/douyin_uploader.py index 5c9f840..8b14e3c 100644 --- a/backend/app/services/uploader/douyin_uploader.py +++ b/backend/app/services/uploader/douyin_uploader.py @@ -544,7 +544,7 @@ class DouyinUploader(BaseUploader): if publish_success: return { "success": True, - "message": "发布成功", + "message": "发布成功,待审核", "url": None } if is_timeout: diff --git a/backend/app/services/uploader/xiaohongshu_uploader.py b/backend/app/services/uploader/xiaohongshu_uploader.py index a68cabe..7cdc71f 100644 --- a/backend/app/services/uploader/xiaohongshu_uploader.py +++ b/backend/app/services/uploader/xiaohongshu_uploader.py @@ -171,7 +171,7 @@ class XiaohongshuUploader(BaseUploader): return { "success": True, - "message": "上传成功" if self.publish_date == 0 else "已设置定时发布", + "message": "发布成功,待审核" if self.publish_date == 0 else "已设置定时发布", "url": None } diff --git a/frontend/src/app/publish/page.tsx b/frontend/src/app/publish/page.tsx index c324644..3092f12 100644 --- a/frontend/src/app/publish/page.tsx +++ b/frontend/src/app/publish/page.tsx @@ -36,6 +36,7 @@ export default function PublishPage() { const [publishTime, setPublishTime] = useState(""); const [qrCodeImage, setQrCodeImage] = useState(null); const [qrPlatform, setQrPlatform] = useState(null); + const [isLoadingQR, setIsLoadingQR] = useState(false); // 加载账号和视频列表 useEffect(() => { @@ -111,6 +112,12 @@ export default function PublishPage() { const result = await res.json(); setPublishResults((prev) => [...prev, result]); + // 发布成功后10秒自动清除结果 + if (result.success) { + setTimeout(() => { + setPublishResults((prev) => prev.filter((r) => r !== result)); + }, 10000); + } } catch (error) { setPublishResults((prev) => [ ...prev, @@ -155,6 +162,9 @@ export default function PublishPage() { }, [qrPlatform]); const handleLogin = async (platform: string) => { + setIsLoadingQR(true); + setQrPlatform(platform); // 立即显示加载弹窗 + setQrCodeImage(null); // 清空旧二维码 try { const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, { method: 'POST' @@ -163,13 +173,16 @@ export default function PublishPage() { if (result.success && result.qr_code) { setQrCodeImage(result.qr_code); - setQrPlatform(platform); // SWR hook will automatically start polling since qrPlatform is set } else { + setQrPlatform(null); // 失败时关闭弹窗 alert(result.message || '登录失败'); } } catch (error) { + setQrPlatform(null); // 失败时关闭弹窗 alert(`登录失败: ${error}`); + } finally { + setIsLoadingQR(false); } }; @@ -202,20 +215,29 @@ export default function PublishPage() { return (
{/* QR码弹窗 */} - {qrCodeImage && ( + {qrPlatform && (
-
+

🔐 扫码登录 {qrPlatform}

- QR Code -

- 请使用手机扫码登录 -

+ {isLoadingQR ? ( +
+
+

正在获取二维码...

+
+ ) : qrCodeImage ? ( + <> + QR Code +

+ 请使用手机扫码登录 +

+ + ) : null}
))}