Merge pull request #229 from DerekEXS/fix/douyin-cli-login-overhaul
fix(douyin): overhaul CLI auto-login to handle new creator center UI
This commit is contained in:
@@ -52,6 +52,9 @@ import os
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
# Shell 把 USERNAME 作为环境变量传过来, Python 需要显式取
|
||||
USERNAME = os.environ.get('USERNAME', '')
|
||||
|
||||
DEBUG_PORT = 9222
|
||||
if USERNAME:
|
||||
COOKIE_FILE = f"cookies/douyin_{USERNAME}.json"
|
||||
|
||||
@@ -51,12 +51,12 @@ def _build_login_result(success: bool, status: str, message: str, account_file:
|
||||
async def cookie_auth(account_file):
|
||||
# 抖音无头会撞反爬墙→content/upload 跳登录→误判 cookie 失效(间歇性)。校验必须有头。
|
||||
# 即便有头,页面慢/瞬时跳转仍会让 wait_for_url(精确URL,5s) 误判→重试3次+宽松判定(URL含 content/upload 且无登录文案)。
|
||||
# 允许 linux server 用户通过 env var 强制无头: DOUYIN_COOKIE_AUTH_HEADLESS=true
|
||||
use_headless = os.environ.get("DOUYIN_COOKIE_AUTH_HEADLESS", "").lower() in ("1", "true", "yes")
|
||||
launch_kwargs = {"headless": use_headless, "channel": "chrome", "args": ["--no-sandbox", "--disable-blink-features=AutomationControlled"]}
|
||||
for _attempt in range(3):
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=False, channel="chrome",
|
||||
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
browser = await playwright.chromium.launch(**launch_kwargs)
|
||||
try:
|
||||
context = await browser.new_context(storage_state=account_file)
|
||||
context = await set_init_script(context)
|
||||
@@ -73,13 +73,13 @@ async def cookie_auth(account_file):
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
@@ -87,26 +87,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:
|
||||
@@ -165,6 +177,16 @@ async def _wait_for_douyin_login(page: Page, account_file: str, qrcode_info: dic
|
||||
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("😵", "二维码失效了,小人马上去刷新"))
|
||||
@@ -181,13 +203,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)
|
||||
@@ -224,7 +253,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
|
||||
|
||||
@@ -589,7 +619,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"],
|
||||
@@ -807,7 +837,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"],
|
||||
|
||||
@@ -155,7 +155,7 @@ async def cookie_auth(account_file):
|
||||
if LOCAL_CHROME_PATH:
|
||||
browser = await playwright.chromium.launch(headless=True, executable_path=LOCAL_CHROME_PATH)
|
||||
else:
|
||||
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)
|
||||
@@ -174,14 +174,14 @@ async def cookie_auth(account_file):
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def ks_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS):
|
||||
async def ks_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS, cdp_url: str | None = None):
|
||||
account_file = get_absolute_path(account_file, "ks_uploader")
|
||||
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
|
||||
kuaishou_logger.info(_msg("🥹", "cookie 失效了,准备重新登录快手创作者平台"))
|
||||
result = await get_ks_cookie(account_file, qrcode_callback=qrcode_callback, headless=headless)
|
||||
result = await get_ks_cookie(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)
|
||||
@@ -194,16 +194,23 @@ async def get_ks_cookie(
|
||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||
poll_interval: int = 3,
|
||||
max_checks: int = 100,
|
||||
cdp_url: str | None = None,
|
||||
):
|
||||
if headless:
|
||||
kuaishou_logger.info(_msg("🖼️", "快手登录将以无头模式运行,小人会输出终端二维码并保存本地二维码图片"))
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
if LOCAL_CHROME_PATH:
|
||||
browser = await playwright.chromium.launch(headless=headless, executable_path=LOCAL_CHROME_PATH)
|
||||
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="chrome")
|
||||
context = await browser.new_context()
|
||||
if LOCAL_CHROME_PATH:
|
||||
browser = await playwright.chromium.launch(headless=headless, executable_path=LOCAL_CHROME_PATH)
|
||||
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
|
||||
qrcode_info = None
|
||||
@@ -265,7 +272,8 @@ async def get_ks_cookie(
|
||||
kuaishou_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
|
||||
if not result["success"]:
|
||||
kuaishou_logger.error(_msg("😢", f"登录失败: {result['message']}"))
|
||||
await context.close()
|
||||
if should_close_context:
|
||||
await context.close()
|
||||
await browser.close()
|
||||
|
||||
return result
|
||||
@@ -453,7 +461,7 @@ class KSVideo(KSBaseUploader):
|
||||
else:
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=self.headless,
|
||||
channel="chrome",
|
||||
channel="chromium",
|
||||
)
|
||||
context = await browser.new_context(storage_state=self.account_file)
|
||||
context = await set_init_script(context)
|
||||
@@ -699,7 +707,7 @@ class KSNote(KSBaseUploader):
|
||||
else:
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=self.headless,
|
||||
channel="chrome",
|
||||
channel="chromium",
|
||||
)
|
||||
context = await browser.new_context(storage_state=self.account_file)
|
||||
context = await set_init_script(context)
|
||||
|
||||
@@ -162,7 +162,7 @@ async def cookie_auth(account_file):
|
||||
if LOCAL_CHROME_PATH:
|
||||
browser = await playwright.chromium.launch(headless=True, executable_path=LOCAL_CHROME_PATH)
|
||||
else:
|
||||
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)
|
||||
@@ -233,7 +233,7 @@ async def xiaohongshu_cookie_gen(
|
||||
account_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=headless, channel="chrome")
|
||||
browser = await playwright.chromium.launch(headless=headless, channel="chromium")
|
||||
context = await browser.new_context()
|
||||
context = await set_init_script(context)
|
||||
qrcode_path = None
|
||||
@@ -619,7 +619,7 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
|
||||
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
|
||||
await self.validate_upload_args()
|
||||
xiaohongshu_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(
|
||||
permissions=["geolocation"],
|
||||
storage_state=self.account_file,
|
||||
@@ -742,7 +742,7 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
|
||||
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
|
||||
await self.validate_upload_args()
|
||||
xiaohongshu_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(
|
||||
permissions=["geolocation"],
|
||||
storage_state=self.account_file,
|
||||
|
||||
Reference in New Issue
Block a user