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:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user