diff --git a/uploader/douyin_uploader/main.py b/uploader/douyin_uploader/main.py index 5b25597..2bb6a72 100644 --- a/uploader/douyin_uploader/main.py +++ b/uploader/douyin_uploader/main.py @@ -50,32 +50,32 @@ def _build_login_result(success: bool, status: str, message: str, account_file: async def cookie_auth(account_file): async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=True, channel="chrome") + browser = await playwright.chromium.launch(headless=True, channel="chromium") try: context = await browser.new_context(storage_state=account_file) context = await set_init_script(context) page = await context.new_page() - await page.goto("https://creator.douyin.com/creator-micro/content/upload") - try: - await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload", timeout=5000) - except Exception: - return False - - if await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count(): - return False - - return True + await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=15000) + # SPA 跳转是异步的,count() 在 race window 内可能为 0 → 假阳性。 + # 改为轮询:最多 10s 内任一稳定文字出现即返回;超时保守判失效。 + for _ in range(20): + if await page.get_by_text("扫码登录").count() or await page.get_by_text("手机号登录").count(): + return False + if await page.get_by_text("上传视频").count(): + return True + await asyncio.sleep(0.5) + return False finally: await browser.close() -async def douyin_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS): +async def douyin_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS, cdp_url: str | None = None): 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 douyin_logger.info(_msg("🥹", "cookie 失效了,准备打开浏览器重新登录")) - result = await douyin_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless) + result = await douyin_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless, cdp_url=cdp_url) return result if return_detail else result["success"] result = _build_login_result(True, "cookie_valid", "cookie有效", account_file) @@ -83,26 +83,38 @@ async def douyin_setup(account_file, handle=False, return_detail=False, qrcode_c async def _extract_douyin_qrcode_src(page: Page) -> str: + # 等 SPA 加载完成(不只等"扫码登录"文字,否则抖音慢加载时 30s 就超时)。 + # 给 domcontentloaded 后足够时间让客户端 JS 注入登录卡。 + try: + await page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass scan_login_tab = page.get_by_text("扫码登录", exact=True).first - await scan_login_tab.wait_for(timeout=30000) + # attached 状态:DOM 里出现即可,不要求 visible/渲染完整,避免 race + await scan_login_tab.wait_for(state="attached", timeout=60000) - qrcode_img = ( - scan_login_tab - .locator("..") - .locator("xpath=following-sibling::div[1]") - .locator('img[aria-label="二维码"]') - .first - ) + # 新版抖音创作者中心 (single_tab + animate_qrcode_container) 不再用 aria-label="二维码"。 + # 按优先级兜底多个 selector,至少一个能命中即可。 + qrcode_selectors = [ + 'div#animate_qrcode_container img[src^="data:image"]', + 'div[class*="animate_qrcode_container"] img[src^="data:image"]', + 'div[class*="scan_qrcode_login_content"] img[src^="data:image"]', + 'img[aria-label="二维码"]', + ] + last_err: Exception | None = None + for sel in qrcode_selectors: + qrcode_img = page.locator(sel).first + try: + await qrcode_img.wait_for(state="attached", timeout=10000) + except Exception as e: + last_err = e + continue + src = await qrcode_img.get_attribute("src") + if src: + return src + last_err = RuntimeError(f"selector {sel} 命中但 src 为空") - if not await qrcode_img.count(): - qrcode_img = page.get_by_role("img", name="二维码").first - - await qrcode_img.wait_for(state="visible", timeout=30000) - src = await qrcode_img.get_attribute("src") - if not src: - raise RuntimeError("未获取到抖音登录二维码地址") - - return src + raise RuntimeError(f"未获取到抖音登录二维码地址 (last_err={last_err})") async def _save_douyin_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict: @@ -126,35 +138,38 @@ async def _save_douyin_qrcode(page: Page, account_file: str, previous_qrcode_pat async def _is_douyin_login_completed(page: Page) -> bool: - if not page.url.startswith("https://creator.douyin.com/creator-micro/home"): - return False - - login_markers = [ - page.get_by_text("扫码登录", exact=True).first, - page.get_by_text("手机号登录", exact=True).first, - page.get_by_text("二维码失效", exact=True).first, - page.get_by_role("img", name="二维码").first, - ] - - for marker in login_markers: - if not await marker.count(): - continue - try: - if await marker.is_visible(): - return False - except Exception: - continue - - return True + try: + cookies = await page.context.cookies() + for cookie in cookies: + if cookie.get("name") == "sessionid" and cookie.get("value"): + return True + except Exception: + pass + return False -async def _wait_for_douyin_login(page: Page, account_file: str, qrcode_info: dict, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 100) -> dict: +async def _wait_for_douyin_login(page: Page, account_file: str, qrcode_info: dict, qrcode_callback=None, poll_interval: int = 2, max_checks: int = 100) -> dict: + # 抖音扫码后流程:扫码 → APP 点确认 → URL 立即变(即使二验页)/ sessionid 较晚才生成 + # 旧实现只等 sessionid,二验时会超时。改为 URL 变化 + sessionid 双判定。 + original_url = page.url qrcode_path = Path(qrcode_info["image_path"]) - for _ in range(max_checks): + saw_2fa = False + + for i in range(max_checks): if await _is_douyin_login_completed(page): douyin_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}")) return _build_login_result(True, "success", "抖音扫码登录成功", account_file, qrcode_info, page.url) + # URL 变化 + sessionid 未到位 → 二验流程,继续等 + if page.url != original_url and not await _is_douyin_login_completed(page): + sms_input = page.locator('input[placeholder*="验证码"], input[type="tel"], input[placeholder*="短信"], input[placeholder*="手机号"]') + if await sms_input.count() > 0: + if not saw_2fa: + douyin_logger.warning(_msg("⚠️", f"检测到抖音短信/安全二次验证,请在弹出的浏览器中手动输入。等待 sessionid ({i}/{max_checks})")) + saw_2fa = True + await asyncio.sleep(poll_interval) + continue + expired_box = page.get_by_text("二维码失效", exact=True).locator("..").first if await expired_box.count() and await expired_box.is_visible(): douyin_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新")) @@ -171,13 +186,20 @@ async def _wait_for_douyin_login(page: Page, account_file: str, qrcode_info: dic async def douyin_cookie_gen( account_file, qrcode_callback=None, - poll_interval: int = 3, - max_checks: int = 100, + poll_interval: int = 2, + max_checks: int = 60, headless: bool = LOCAL_CHROME_HEADLESS, + cdp_url: str | None = None, ): async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=headless, channel="chrome") - context = await browser.new_context() + if cdp_url: + browser = await playwright.chromium.connect_over_cdp(cdp_url) + context = browser.contexts[0] if browser.contexts else await browser.new_context() + should_close_context = False + else: + browser = await playwright.chromium.launch(headless=headless, channel="chromium") + context = await browser.new_context() + should_close_context = True context = await set_init_script(context) qrcode_path = None result = _build_login_result(False, "failed", "抖音登录失败", account_file) @@ -214,7 +236,8 @@ async def douyin_cookie_gen( douyin_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}")) if not result["success"]: douyin_logger.error(_msg("😢", f"登录失败: {result['message']}")) - await context.close() + if should_close_context: + await context.close() await browser.close() return result @@ -500,7 +523,7 @@ class DouYinVideo(DouYinBaseUploader): await self.validate_upload_args() douyin_logger.info(_msg("🥳", "上传前检查通过")) - browser = await playwright.chromium.launch(headless=self.headless, channel="chrome") + browser = await playwright.chromium.launch(headless=self.headless, channel="chromium") context = await browser.new_context( storage_state=f"{self.account_file}", permissions=["geolocation"], @@ -695,7 +718,7 @@ class DouYinNote(DouYinBaseUploader): await self.validate_upload_args() douyin_logger.info(_msg("🥳", "图文上传前检查通过")) - browser = await playwright.chromium.launch(headless=self.headless, channel="chrome") + browser = await playwright.chromium.launch(headless=self.headless, channel="chromium") context = await browser.new_context( storage_state=f"{self.account_file}", permissions=["geolocation"],