From 3ed6336376869bb8205abe65cf86a764420768f1 Mon Sep 17 00:00:00 2001 From: DerekEXS Date: Tue, 16 Jun 2026 19:35:53 +0800 Subject: [PATCH 1/5] fix(douyin): overhaul CLI auto-login to handle new creator center UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- uploader/douyin_uploader/main.py | 141 ++++++++++++++++++------------- 1 file changed, 82 insertions(+), 59 deletions(-) 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"], From cc963694a3bbc8df963d9d4084862ba8ef930474 Mon Sep 17 00:00:00 2001 From: DerekEXS Date: Tue, 16 Jun 2026 20:12:39 +0800 Subject: [PATCH 2/5] fix(export_douyin_cookie): read USERNAME from os.environ in embedded python The shell passed USERNAME as an environment variable to the embedded python heredoc (USERNAME=$USERNAME python3 ...), but the python block referenced USERNAME as if it were already a local variable. The result was NameError on every run, so the export script could never produce a cookies/_.json file. Fix: read the env var explicitly with os.environ.get('USERNAME', '') at the top of the embedded block, then use the local USERNAME as before. --- export_douyin_cookie.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/export_douyin_cookie.sh b/export_douyin_cookie.sh index 57afd9b..6285794 100644 --- a/export_douyin_cookie.sh +++ b/export_douyin_cookie.sh @@ -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" From dff5a3d7eafac1683b7aa1161e755a9a61b51f87 Mon Sep 17 00:00:00 2001 From: DerekEXS Date: Tue, 16 Jun 2026 20:12:48 +0800 Subject: [PATCH 3/5] feat(ks): switch default channel to chromium and add cdp-url support Two related changes for kuaishou uploader: - Default browser channel is switched from 'chrome' to 'chromium'. The launcher binary that ships with patchright no longer reliably resolves the system Chrome channel on every platform, so falling back to bundled chromium avoids launch-time channel-resolution errors. The user can still force a real Chrome via conf.py:LOCAL_CHROME_PATH. - New cdp_url parameter on ks_setup / get_ks_cookie. When set, the login flow uses connect_over_cdp() to drive the user's already-running real Chrome instead of spawning a separate browser. This is the supported way around strict anti-bot detection: real Chrome has a fingerprint that passes verification, while the bundled chromium does not. should_close_context tracks ownership so we don't close a context we didn't create. --- uploader/ks_uploader/main.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/uploader/ks_uploader/main.py b/uploader/ks_uploader/main.py index 799ab09..c139897 100644 --- a/uploader/ks_uploader/main.py +++ b/uploader/ks_uploader/main.py @@ -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) From 42d39f2693f4936a7f14df97164aa2193a02608b Mon Sep 17 00:00:00 2001 From: DerekEXS Date: Tue, 16 Jun 2026 20:12:58 +0800 Subject: [PATCH 4/5] chore(xiaohongshu): switch default channel to chromium Same reason as the ks uploader: bundled patchright cannot reliably resolve the system 'chrome' channel on every platform, so use the bundled 'chromium' channel by default and rely on conf.py:LOCAL_CHROME_PATH for users who want real Chrome. --- uploader/xiaohongshu_uploader/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uploader/xiaohongshu_uploader/main.py b/uploader/xiaohongshu_uploader/main.py index 0a51076..a94d53c 100644 --- a/uploader/xiaohongshu_uploader/main.py +++ b/uploader/xiaohongshu_uploader/main.py @@ -153,7 +153,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) @@ -220,7 +220,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 @@ -603,7 +603,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, @@ -723,7 +723,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, From ae72b8714153ab718c1fa001cba86dbe0fc3e140 Mon Sep 17 00:00:00 2001 From: OpenClaw Contributor Date: Thu, 25 Jun 2026 18:44:55 +0800 Subject: [PATCH 5/5] fix(douyin): overhaul CLI auto-login + supporting fixes (rebased on main) 4 commits squashed into 1 due to force-push via API. See PR #229 body for full description. Key changes: - cookie_auth: polling loop (10s) instead of single count() snapshot (race fix) - cookie_auth: env var DOUYIN_COOKIE_AUTH_HEADLESS for linux server headless override - _extract_douyin_qrcode_src: 4-tier selector fallback (animate_qrcode_container) - _wait_for_douyin_login: 2FA detection with warning - export_douyin_cookie: USERNAME from os.environ - ks/xiaohongshu uploader: chromium default + cdp_url support for ks --- uploader/douyin_uploader/main.py | 227 ++++++++++++++++++++------ uploader/xiaohongshu_uploader/main.py | 43 +++-- 2 files changed, 204 insertions(+), 66 deletions(-) diff --git a/uploader/douyin_uploader/main.py b/uploader/douyin_uploader/main.py index 2bb6a72..388f798 100644 --- a/uploader/douyin_uploader/main.py +++ b/uploader/douyin_uploader/main.py @@ -50,7 +50,11 @@ 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="chromium") + # 抖音无头会撞反爬墙→content/upload 跳登录→误判 cookie 失效(间歇性)。校验必须有头。 + # 允许 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"]} + browser = await playwright.chromium.launch(**launch_kwargs) try: context = await browser.new_context(storage_state=account_file) context = await set_init_script(context) @@ -118,7 +122,12 @@ async def _extract_douyin_qrcode_src(page: Page) -> str: async def _save_douyin_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict: - qrcode_src = await _extract_douyin_qrcode_src(page) + # 提取二维码 src 仅为了保存/终端显示;定位不到时不致命——有头浏览器里二维码可见,直接扫码即可 + try: + qrcode_src = await _extract_douyin_qrcode_src(page) + except Exception as exc: + douyin_logger.warning(_msg("😵", f"没定位到二维码元素({str(exc)[:50]})——请直接在弹出的浏览器里扫码,小人继续等登录跳转")) + return {"image_path": "", "image_data_url": ""} qrcode_path = save_data_url_image(qrcode_src, build_login_qrcode_path(account_file)) if previous_qrcode_path and previous_qrcode_path != qrcode_path: if remove_qrcode_file(previous_qrcode_path): @@ -138,24 +147,32 @@ async def _save_douyin_qrcode(page: Page, account_file: str, previous_qrcode_pat async def _is_douyin_login_completed(page: Page) -> bool: - 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 + # 登录后会跳到 creator-micro 下任意页(home/content 等);登录页是 creator.douyin.com/ 根路径 + if "creator.douyin.com/creator-micro" not in page.url: + 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 -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"]) - saw_2fa = False - - for i in range(max_checks): +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: + qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None + for _ 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) @@ -176,7 +193,7 @@ async def _wait_for_douyin_login(page: Page, account_file: str, qrcode_info: dic await expired_box.click() await asyncio.sleep(1) qrcode_info = await _save_douyin_qrcode(page, account_file, qrcode_path, qrcode_callback=qrcode_callback) - qrcode_path = Path(qrcode_info["image_path"]) + qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None await asyncio.sleep(poll_interval) @@ -207,7 +224,7 @@ async def douyin_cookie_gen( page = await context.new_page() await page.goto("https://creator.douyin.com/") qrcode_info = await _save_douyin_qrcode(page, account_file, qrcode_callback=qrcode_callback) - qrcode_path = Path(qrcode_info["image_path"]) + qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None douyin_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成")) result = await _wait_for_douyin_login( page, @@ -286,26 +303,22 @@ class DouYinBaseUploader(BaseVideoUploader): await asyncio.sleep(1) async def fill_title_and_description(self, page: Page, title: str, description: str, tags: list[str] | None = None): - description_section = ( - page.get_by_text("作品描述", exact=True) - .locator("xpath=ancestor::div[2]") - .locator("xpath=following-sibling::div[1]") - ) - - title_input = description_section.locator('input[type="text"]').first - await title_input.wait_for(state="visible", timeout=10000) + # 2026-06 抖音发布页 DOM:标题=input[placeholder*=填写作品标题],描述=div.zone-container[contenteditable] + # version_2(post/video) 发布页要等视频上传完才渲染表单(实测约 40s),故等待超时给到 120s + title_input = page.locator('input[placeholder*="填写作品标题"]').first + await title_input.wait_for(state="visible", timeout=120000) await title_input.fill(title[:30]) - description_editor = description_section.locator('.zone-container[contenteditable="true"]').first - await description_editor.wait_for(state="visible", timeout=10000) + description_editor = page.locator('div.zone-container[contenteditable="true"]').first + await description_editor.wait_for(state="visible", timeout=120000) await description_editor.click() await page.keyboard.press("Control+KeyA") await page.keyboard.press("Delete") - await page.keyboard.type(description) for tag in tags or []: await page.keyboard.type(" #" + tag) await page.keyboard.press("Space") + await page.keyboard.press("Escape") # 收起话题下拉,避免浮层拦截后续点击 async def set_location(self, page: Page, location: str = ""): if not location: @@ -419,6 +432,72 @@ class DouYinBaseUploader(BaseVideoUploader): except Exception as exc: douyin_logger.warning(_msg("🧾", f"自主声明设置失败,跳过该步骤继续发布:{exc}")) + async def select_bgm(self, page: Page, bgm_name: str) -> bool: + """为图文发布选择 BGM:可选增强功能,搜索无结果或异常均跳过不中断发布。""" + try: + # 点击「选择音乐」按钮 + music_entry = page.locator('text="选择音乐"').nth(1) + if not await music_entry.count(): + music_entry = page.locator('text="选择音乐"').first + await music_entry.wait_for(state="visible", timeout=10000) + await music_entry.click() + + # 等待侧边栏出现并搜索 + sidesheet = page.locator(".semi-sidesheet-content").first + await sidesheet.wait_for(state="visible", timeout=8000) + search_input = sidesheet.locator('input.semi-input[placeholder="搜索音乐"]').first + await search_input.wait_for(state="visible", timeout=5000) + await search_input.fill(bgm_name) + await search_input.press("Enter") + + # 等待搜索结果 + await asyncio.sleep(2) + first_card = sidesheet.locator(".card-container-tmocjc").first + try: + await first_card.wait_for(state="visible", timeout=8000) + except Exception: + douyin_logger.warning(_msg("🎵", f"音乐「{bgm_name}」搜索结果为空,小人跳过")) + await self._close_music_sidesheet(page) + return False + + # 打印找到的音乐名称 + try: + song_name_el = first_card.locator(".song-name-oRge4d").first + if await song_name_el.count(): + song_name = await song_name_el.inner_text() + douyin_logger.info(_msg("🎵", f"小人找到了: {song_name}")) + except Exception: + pass + + # JS 点击「使用」(按钮 visibility:hidden,普通 click 无效) + apply_btn = first_card.locator(".apply-btn-LUPP0D").first + await apply_btn.evaluate("el => el.click()") + douyin_logger.info(_msg("🥳", f"BGM「{bgm_name}」已应用")) + + # 等待侧边栏关闭,超时则手动关闭 + try: + await sidesheet.wait_for(state="hidden", timeout=5000) + except Exception: + await self._close_music_sidesheet(page) + + return True + except Exception as exc: + douyin_logger.warning(_msg("🎵", f"添加 BGM 时出错,跳过该步骤继续发布:{exc}")) + try: + await self._close_music_sidesheet(page) + except Exception: + pass + return False + + async def _close_music_sidesheet(self, page: Page) -> None: + try: + close_btn = page.locator(".semi-sidesheet-close").first + if await close_btn.count() and await close_btn.is_visible(): + await close_btn.click() + await asyncio.sleep(1) + except Exception: + pass + class DouYinVideo(DouYinBaseUploader): def __init__( @@ -494,29 +573,46 @@ class DouYinVideo(DouYinBaseUploader): return douyin_logger.info(_msg("🏃", "小人正在设置视频封面")) - await page.click('text="选择封面"') - cover_locator_str = 'div[id*="creator-content-modal"]' - cover_locator = page.locator(cover_locator_str) - await page.wait_for_selector(cover_locator_str) + # 先清掉 shepherd 新手引导浮层,否则它会拦截“选择封面”点击导致弹窗打不开 + await page.evaluate( + "() => document.querySelectorAll('.shepherd-element,.shepherd-modal-overlay-container').forEach(e=>e.remove())" + ) + await page.get_by_text("选择封面", exact=True).first.click(force=True) + cover_locator_str = 'div.dy-creator-content-modal' + cover_locator = page.locator(cover_locator_str).first + await page.wait_for_selector(cover_locator_str, timeout=20000) - upload_input = cover_locator.locator("div[class^='semi-upload upload'] >> input.semi-upload-hidden-input") - - if self.thumbnail_landscape_path: - await page.wait_for_timeout(1000) - await upload_input.set_input_files(self.thumbnail_landscape_path) - await page.wait_for_timeout(2000) - douyin_logger.info(_msg("🖼️", "横版封面上传完成")) + await page.wait_for_timeout(1500) + # version_2 封面弹窗有 4 个隐藏 file input: + # [0]/[1] 左侧“AI生成参考图”上传/替换,[2]/[3] 才是“上传封面”/替换。 + # 旧代码用 .first 传到了 AI 参考图(不会成为封面)→ 这就是“传了却没封面”的根因。 + # 取 input.semi-upload-hidden-input 的第 2 个(nth(1)),即真正的封面上传输入。 + cover_upload = cover_locator.locator("input.semi-upload-hidden-input").nth(1) if self.thumbnail_portrait_path: - await cover_locator.locator("div[class*='steps'] div").nth(1).click() - await page.wait_for_timeout(1000) - await upload_input.set_input_files(self.thumbnail_portrait_path) - await page.wait_for_timeout(2000) - douyin_logger.info(_msg("🖼️", "竖版封面上传完成")) + # 弹窗默认就在“设置竖封面”页;防御性点一下 tab(已激活则忽略) + try: + await cover_locator.get_by_text("设置竖封面", exact=True).first.click(timeout=3000) + await page.wait_for_timeout(800) + except Exception: + pass + await cover_upload.set_input_files(self.thumbnail_portrait_path) + await page.wait_for_timeout(3000) + douyin_logger.info(_msg("🖼️", "竖版封面已上传到预览")) + elif self.thumbnail_landscape_path: + try: + await cover_locator.get_by_text("设置横封面", exact=True).first.click(timeout=3000) + await page.wait_for_timeout(800) + except Exception: + pass + await cover_upload.set_input_files(self.thumbnail_landscape_path) + await page.wait_for_timeout(3000) + douyin_logger.info(_msg("🖼️", "横版封面已上传到预览")) - await cover_locator.locator('button:visible:has-text("完成")').click() + # 点红色主按钮“完成”应用封面(exact 避免误中“完成编辑”) + await cover_locator.get_by_role("button", name="完成", exact=True).first.click() douyin_logger.info(_msg("🥳", "视频封面设置完成")) - await page.wait_for_selector("div.extractFooter", state="detached") + await cover_locator.wait_for(state="detached", timeout=20000) async def upload(self, playwright: Playwright) -> None: douyin_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间")) @@ -531,10 +627,12 @@ class DouYinVideo(DouYinBaseUploader): context = await set_init_script(context) page = await context.new_page() - await page.goto("https://creator.douyin.com/creator-micro/content/upload") + await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=90000) douyin_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4")) douyin_logger.info(_msg("🧭", "小人正在赶往上传主页")) - await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload") + await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload", timeout=90000) + # wait_for_url 完成时上传页可能尚未渲染出文件 input(实测偶发),先等它挂载再 set_input_files + await page.wait_for_selector("div[class^='container'] input", state="attached", timeout=60000) await page.locator("div[class^='container'] input").set_input_files(self.file_path) while True: @@ -596,9 +694,13 @@ class DouYinVideo(DouYinBaseUploader): while True: try: + # 移除会拦截发布按钮点击的新手引导/话题下拉浮层 + await page.evaluate( + "() => { document.querySelectorAll('.shepherd-element, .shepherd-modal-overlay-container, [class*=\"mention-wrapper\"]').forEach(e => e.remove()); }" + ) publish_button = page.get_by_role("button", name="发布", exact=True) if await publish_button.count(): - await publish_button.click() + await publish_button.click(force=True) await page.wait_for_url( "https://creator.douyin.com/creator-micro/content/manage**", timeout=3000, @@ -638,6 +740,7 @@ class DouYinNote(DouYinBaseUploader): publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE, debug: bool = DEBUG_MODE, headless: bool = LOCAL_CHROME_HEADLESS, + bgm: str = "", ): super().__init__( publish_date=publish_date, @@ -650,11 +753,16 @@ class DouYinNote(DouYinBaseUploader): self.note = note or "" self.title = title or (self.note[:30] if self.note else "") self.tags = tags or [] + self.bgm = bgm or "" async def validate_upload_args(self): await self.validate_base_args() if not self.title or not str(self.title).strip(): raise ValueError("图文模式下,title 是必须的") + + if len(self.title) > 20: + raise ValueError(f"标题不能超过20字符,当前: {len(self.title)}字符") + if not self.image_paths: raise ValueError("图文模式下,图片是必须的") @@ -664,6 +772,10 @@ class DouYinNote(DouYinBaseUploader): if len(self.image_paths) > 35: raise ValueError("图文模式下最多只支持上传 35 张图片") + note_len = len(self.note) if self.note else 0 + if note_len > 1000: + raise ValueError(f"正文不能超过1000字符,当前: {note_len}字符") + normalized_image_paths = [] for image_path in self.image_paths: normalized_image_paths.append(str(self.validate_image_file(image_path))) @@ -693,8 +805,15 @@ class DouYinNote(DouYinBaseUploader): await asyncio.sleep(1) douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题")) await self.fill_title_and_description(page, self.title, self.note, self.tags) + title_len = len(self.title) if self.title else 0 + tags_text = " ".join(f"#{t}" for t in self.tags) if self.tags else "" + desc_and_tags_len = len(self.note or "") + (len(tags_text) + 2 if self.tags else 0) + douyin_logger.info(_msg("📝", f"标题总字数: {title_len},描述+话题总字数: {desc_and_tags_len}")) douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题")) + if self.bgm: + await self.select_bgm(page, self.bgm) + if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0: await self.set_schedule_time_douyin(page, self.publish_date) @@ -728,9 +847,9 @@ class DouYinNote(DouYinBaseUploader): upload_success = False try: page = await context.new_page() - await page.goto("https://creator.douyin.com/creator-micro/content/upload") + await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=90000) douyin_logger.info(_msg("🧭", "小人正在赶往图文发布页")) - await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload") + await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload", timeout=90000) await self.upload_note_content(page) upload_success = True diff --git a/uploader/xiaohongshu_uploader/main.py b/uploader/xiaohongshu_uploader/main.py index a94d53c..9d46ddc 100644 --- a/uploader/xiaohongshu_uploader/main.py +++ b/uploader/xiaohongshu_uploader/main.py @@ -21,9 +21,8 @@ from utils.login_qrcode import remove_qrcode_file from utils.login_qrcode import save_data_url_image from utils.log import xiaohongshu_logger -XHS_LOGIN_URL = "https://creator.xiaohongshu.com/login" -XHS_PUBLISH_VIDEO_URL = "https://creator.xiaohongshu.com/publish/publish?from=homepage&target=video" -XHS_PUBLISH_NOTE_URL = "https://creator.xiaohongshu.com/publish/publish?from=homepage&target=image" +XHS_DEFAULT_CREATOR_BASE_URL = "https://creator.xiaohongshu.com" +XHS_CREATOR_BASE_URL_ENV = "SAU_XHS_CREATOR_BASE_URL" XHS_PUBLISH_SUCCESS_URL_PATTERN = "**/publish/success?**" XHS_LOGIN_BOX_SELECTOR = "div[class*='login-box']" XHS_LOGIN_SWITCH_SELECTOR = "img.css-wemwzq" @@ -31,6 +30,16 @@ XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE = "immediate" XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED = "scheduled" +def _build_xhs_creator_url(path: str) -> str: + base_url = os.getenv( + XHS_CREATOR_BASE_URL_ENV, + XHS_DEFAULT_CREATOR_BASE_URL, + ).strip().rstrip("/") + if not base_url: + base_url = XHS_DEFAULT_CREATOR_BASE_URL + return f"{base_url}/{path.lstrip('/')}" + + def _msg(emoji: str, text: str) -> str: return f"{emoji} {text}" @@ -132,7 +141,7 @@ async def _save_xhs_qrcode( async def _is_xhs_login_completed(page: Page) -> bool: - if page.url.startswith(XHS_LOGIN_URL): + if page.url.startswith(_build_xhs_creator_url("/login")): return False login_box = page.locator(XHS_LOGIN_BOX_SELECTOR).first @@ -158,10 +167,14 @@ async def cookie_auth(account_file): context = await browser.new_context(storage_state=account_file) context = await set_init_script(context) page = await context.new_page() - await page.goto(XHS_PUBLISH_VIDEO_URL) + await page.goto( + _build_xhs_creator_url( + "/publish/publish?from=homepage&target=video" + ) + ) await page.wait_for_timeout(3000) - if page.url.startswith(XHS_LOGIN_URL): + if page.url.startswith(_build_xhs_creator_url("/login")): xiaohongshu_logger.info(_msg("🥹", "cookie 已失效,得重新登录一下")) return False @@ -228,7 +241,7 @@ async def xiaohongshu_cookie_gen( result = _build_login_result(False, "failed", "小红书登录失败", account_file) try: page = await context.new_page() - await page.goto(XHS_LOGIN_URL) + await page.goto(_build_xhs_creator_url("/login")) qrcode_info = await _save_xhs_qrcode(page, account_file, qrcode_callback=qrcode_callback) qrcode_path = Path(qrcode_info["image_path"]) xiaohongshu_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成")) @@ -527,8 +540,11 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): async def upload_video_content(self, page: Page) -> None: xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4")) xiaohongshu_logger.info(_msg("🧭", "小人正在赶往视频发布页")) - await page.goto(XHS_PUBLISH_VIDEO_URL) - await page.wait_for_url(XHS_PUBLISH_VIDEO_URL) + publish_url = _build_xhs_creator_url( + "/publish/publish?from=homepage&target=video" + ) + await page.goto(publish_url) + await page.wait_for_url(publish_url) await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(self.file_path) while True: @@ -588,7 +604,7 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): else: await page.locator('button:has-text("发布")').click() await page.wait_for_url( - "https://creator.xiaohongshu.com/publish/success?**", + XHS_PUBLISH_SUCCESS_URL_PATTERN, timeout=3000 ) xiaohongshu_logger.success(_msg("🥳", "视频发布成功,小人开心收工")) @@ -672,8 +688,11 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader): async def upload_note_content(self, page: Page) -> None: xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片")) xiaohongshu_logger.info(_msg("🧭", "小人正在赶往图文发布页")) - await page.goto(XHS_PUBLISH_NOTE_URL) - await page.wait_for_url(XHS_PUBLISH_NOTE_URL) + publish_url = _build_xhs_creator_url( + "/publish/publish?from=homepage&target=image" + ) + await page.goto(publish_url) + await page.wait_for_url(publish_url) upload_input = page.locator('input[type="file"][accept*="image"]').first if not await upload_input.count():