import asyncio import logging from core.browser import douyin_network_modes, get_browser logger = logging.getLogger(__name__) CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat" FRIENDS_TAB_SELECTOR = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]' # Douyin's chat page is a virtualized list and its generated wrapper classes and # child indexes change frequently. Keep semantic/current selectors first, with # the historical XPath selectors last for older page variants. FRIEND_ROW_SELECTORS = ( '#sub-app li[role="listitem"]:has([class*="item-header-name-"])', '#sub-app li.semi-list-item:has([class*="item-header-name-"])', 'xpath=//*[@id="sub-app"]//div[contains(@class, "semi-list-item-body") and .//*[contains(@class, "item-header-name-")]]', 'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]', ) SCROLLABLE_FRIENDS_SELECTORS = ( '#sub-app [role="grid"]', '#sub-app .ReactVirtualized__Grid', '#sub-app [class*="semi-list"] ul', '#sub-app ul > div', 'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div', ) NO_MORE_SELECTORS = ( 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]', 'xpath=//*[@id="sub-app"]//*[contains(normalize-space(.), "没有更多")]', 'xpath=//*[@id="sub-app"]//*[contains(normalize-space(.), "暂无")]', ) LOADING_SELECTORS = ( 'xpath=//div[contains(@class, "semi-spin")]', '#sub-app [class*="loading"]', '#sub-app [class*="Loading"]', ) FRIEND_NAME_SELECTORS = ( 'xpath=.//*[contains(@class, "item-header-name-")]', '[class*="item-header-name-"]', ) # Backward-compatible aliases for callers/tests that imported the old constants. TARGET_SELECTOR = FRIEND_ROW_SELECTORS[-1] SCROLLABLE_FRIENDS_SELECTOR = SCROLLABLE_FRIENDS_SELECTORS[-1] NO_MORE_SELECTOR = NO_MORE_SELECTORS[0] LOADING_SELECTOR = LOADING_SELECTORS[0] FIRST_FRIEND_SELECTOR = FRIEND_ROW_SELECTORS[0] FRIEND_NAME_SELECTOR = FRIEND_NAME_SELECTORS[0] LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"] NON_LOGIN_DIALOG_DISMISS_TEXTS = ( "我知道了", "知道了", "好的", "确定", "确认", "稍后再说", "关闭", ) NON_LOGIN_DIALOG_CLOSE_SELECTORS = ( ".semi-modal-close", 'button[aria-label="Close"]', 'button[aria-label="关闭"]', '[aria-label="Close"]', '[aria-label="关闭"]', ) FRIEND_LIST_EMPTY_ROUNDS = 6 FRIEND_LIST_EMPTY_WAIT_SECONDS = 1.5 FRIEND_LIST_READY_TIMEOUT_SECONDS = 60 def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2): next_idle_rounds = 0 if new_names_count > 0 else idle_rounds + 1 next_stuck_rounds = 0 if scroll_moved else stuck_rounds + 1 should_stop = no_more_visible or next_idle_rounds >= idle_limit or next_stuck_rounds >= stuck_limit return should_stop, next_idle_rounds, next_stuck_rounds async def _ensure_logged_in(page): for selector in LOGIN_MASK_SELECTORS: try: locator = page.locator(selector).first if await locator.count() > 0 and await locator.is_visible(): raise RuntimeError("账号登录已失效,请重新扫码登录") except RuntimeError: raise except Exception: continue async def _dismiss_non_login_dialogs(page): for text in NON_LOGIN_DIALOG_DISMISS_TEXTS: try: locator = page.get_by_text(text, exact=False).first if await locator.count() > 0 and await locator.is_visible(): await locator.click(timeout=3000) await asyncio.sleep(1) return True except Exception: continue for selector in NON_LOGIN_DIALOG_CLOSE_SELECTORS: try: locator = page.locator(selector).first if await locator.count() > 0 and await locator.is_visible(): await locator.click(timeout=3000) await asyncio.sleep(1) return True except Exception: continue return False async def _click_friends_tab(page): await page.wait_for_selector("#sub-app", timeout=FRIEND_LIST_READY_TIMEOUT_SECONDS * 1000) try: await page.get_by_role("tab", name="朋友私信", exact=True).click(timeout=5000) return except Exception: pass try: await page.locator(FRIENDS_TAB_SELECTOR).click(timeout=10000) return except Exception: pass try: await page.get_by_text("朋友私信", exact=True).click(timeout=10000) return except Exception as exc: raise RuntimeError("未找到朋友私信标签") from exc async def _friend_list_dom_summary(page): return await page.evaluate( """() => { const sub = document.querySelector('#sub-app'); if (!sub) { return { hasSubApp: false, ulCount: 0, liCount: 0, listItemCount: 0, nameSpanCount: 0, text: '' }; } return { hasSubApp: true, ulCount: sub.querySelectorAll('ul').length, liCount: sub.querySelectorAll('li').length, listItemCount: sub.querySelectorAll('[class*="list-item"]').length, nameSpanCount: sub.querySelectorAll('[class*="item-header-name"]').length, text: (sub.innerText || '').split(String.fromCharCode(10)).join(' ').slice(0, 500), }; }""" ) async def _first_visible_locator(page, selectors): for selector in selectors: try: locator = page.locator(selector) count = await locator.count() for index in range(min(count, 5)): item = locator.nth(index) if await item.is_visible(timeout=500): return selector, locator except Exception: continue return "", None async def _wait_for_friend_rows_or_empty(page, timeout_seconds=FRIEND_LIST_READY_TIMEOUT_SECONDS): started_at = asyncio.get_running_loop().time() while asyncio.get_running_loop().time() - started_at < timeout_seconds: await _dismiss_non_login_dialogs(page) selector, locator = await _first_visible_locator(page, FRIEND_ROW_SELECTORS) if locator: logger.debug("Friend list ready via selector %s", selector) return selector, locator summary = await _friend_list_dom_summary(page) if int(summary.get("nameSpanCount") or 0) > 0: selector, locator = await _first_visible_locator(page, FRIEND_ROW_SELECTORS) if locator: return selector, locator loading_selector, _ = await _first_visible_locator(page, LOADING_SELECTORS) if loading_selector: await asyncio.sleep(FRIEND_LIST_EMPTY_WAIT_SECONDS) continue # A genuinely empty friend list is valid; do not turn it into a timeout. text = str(summary.get("text") or "") if any(marker in text for marker in ("暂无", "没有更多")): return "", None await asyncio.sleep(0.5) summary = await _friend_list_dom_summary(page) raise RuntimeError( "friend list did not become ready within timeout; " f"dom={summary}" ) async def _wait_for_chat_or_login(page, timeout_seconds=FRIEND_LIST_READY_TIMEOUT_SECONDS): deadline = asyncio.get_running_loop().time() + timeout_seconds while asyncio.get_running_loop().time() < deadline: await _ensure_logged_in(page) try: if await page.locator("#sub-app").count() > 0: return except Exception: pass await asyncio.sleep(0.5) raise RuntimeError("chat page did not load within timeout") async def collect_friend_names(page): await _wait_for_chat_or_login(page) await _click_friends_tab(page) _, target_locator = await _wait_for_friend_rows_or_empty(page) if not target_locator: return [] found_names = [] seen_names = set() idle_rounds = 0 stuck_rounds = 0 while True: _, target_locator = await _first_visible_locator(page, FRIEND_ROW_SELECTORS) if not target_locator: if found_names: return found_names raise RuntimeError("好友列表已加载但未找到可读取的好友行") target_elements = await target_locator.all() new_names_count = 0 for element in target_elements: name = "" for selector in FRIEND_NAME_SELECTORS: try: name = (await element.locator(selector).first.inner_text(timeout=1000)).strip() except Exception: continue if name: break if not name: try: name = (await element.inner_text(timeout=1000)).splitlines()[0].strip() except Exception: continue if not name or name in seen_names: continue seen_names.add(name) found_names.append(name) new_names_count += 1 no_more_selector, _ = await _first_visible_locator(page, NO_MORE_SELECTORS) if no_more_selector: return found_names loading_selector, _ = await _first_visible_locator(page, LOADING_SELECTORS) if loading_selector: await asyncio.sleep(1.5) scrollable_element = None scrollable_selector = "" for selector in SCROLLABLE_FRIENDS_SELECTORS: try: candidate = page.locator(selector).first handle = await candidate.element_handle() if not handle: continue metrics = await page.evaluate( """(element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })""", handle, ) if int(metrics.get("clientHeight") or 0) > 0: scrollable_element = handle scrollable_selector = selector break except Exception: continue if not scrollable_element: if found_names: return found_names raise RuntimeError("未找到好友列表滚动容器") before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element) await page.evaluate("(element) => element.scrollTop += 800", scrollable_element) await asyncio.sleep(1.5) after_top = await page.evaluate("(element) => element.scrollTop", scrollable_element) logger.debug( "Friend list refresh scroll selector=%s before=%s after=%s names=%s", scrollable_selector, before_top, after_top, len(found_names), ) should_stop, idle_rounds, stuck_rounds = update_collection_progress( new_names_count=new_names_count, no_more_visible=False, scroll_moved=after_top > before_top, idle_rounds=idle_rounds, stuck_rounds=stuck_rounds, ) if should_stop: return found_names async def _fetch_account_friends_once(account, network_mode): cookies = list(account.get("cookies") or []) playwright = browser = context = page = None try: playwright, browser = await get_browser(GUI=False, network_mode=network_mode) context = await browser.new_context() context.set_default_navigation_timeout(120000) context.set_default_timeout(120000) page = await context.new_page() await context.add_cookies(cookies) await page.goto(CHAT_PAGE_URL, wait_until="commit", timeout=FRIEND_LIST_READY_TIMEOUT_SECONDS * 1000) await asyncio.sleep(1) friends = await collect_friend_names(page) return friends except RuntimeError: raise except Exception as exc: raise RuntimeError(f"刷新好友列表失败,请重试:{exc}") from exc finally: if page: await page.close() if context: await context.close() if browser: await browser.close() if playwright: await playwright.stop() async def fetch_account_friends(account): cookies = list(account.get("cookies") or []) if not cookies: raise RuntimeError("account has no cookies; scan login QR code first") modes = douyin_network_modes() last_error = None for index, network_mode in enumerate(modes): try: friends = await _fetch_account_friends_once(account, network_mode) logger.info( "Friend refresh route=%s count=%s attempt=%s/%s", network_mode, len(friends), index + 1, len(modes), ) if friends or index == len(modes) - 1: return friends logger.warning( "Friend refresh route=%s returned zero friends; trying next route", network_mode, ) except RuntimeError as exc: text = str(exc).lower() if any(marker in text for marker in ("login", "cookie", "scan", "登录", "扫码")): raise last_error = exc logger.warning("Friend refresh route=%s failed; trying next route: %s", network_mode, exc) except Exception as exc: last_error = exc logger.warning("Friend refresh route=%s failed; trying next route: %s", network_mode, exc) raise RuntimeError(f"friend refresh failed after routes {modes}: {last_error}")