fix(douyin): overhaul CLI auto-login to handle new creator center UI

The Douyin creator center UI (https://creator.douyin.com/creator-micro/) has been refactored and breaks the existing login flow in three independent ways. This commit fixes all of them.

1. QR code selector no longer matches
   The previous selector chain assumed the QR sat in a sibling div with an aria-label of 二维码. After the refactor, the QR is rendered as a base64 PNG inside div#animate_qrcode_container and has no aria-label. The new chain is a 4-tier fallback (id -> class partial -> container partial -> aria-label) preceded by a networkidle wait so the SPA is fully hydrated before we look.

2. cookie_auth() race condition
   page.goto + wait_for_url(5000) + immediate get_by_text count() was racy: the SPA had not finished hydrating, so count() could return 0 even when the page actually shows the login card, leading to false-positive 'cookie valid' results. Replaced with a 10s polling loop that returns invalid if neither the login-card text nor the upload-page text is observed.

3. _wait_for_douyin_login() only waited for sessionid cookie (120s)
   During 2FA the cookie is not yet set; the page is still on the verification screen and the URL has already changed. The new loop detects 2FA input fields, logs a warning so the user knows to type the SMS code, and keeps polling. Timeout raised to 200s to accommodate the slower human-in-the-loop flow.

Verified the selector chain against the live creator.douyin.com page: returns a 2.9 KB base64 PNG that decodes into a valid douyin scan_login URL.
This commit is contained in:
DerekEXS
2026-06-16 19:35:53 +08:00
parent 90e01c0106
commit 3ed6336376
+82 -59
View File
@@ -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"],