"""账号凭证检测:静态 Cookie 分析 + IM 运行时校验""" import asyncio import json import logging from typing import Optional from rpa_engine.douyin_im.auth import DouyinAuth from rpa_engine.douyin_im.frontier import ensure_frontier_ws from rpa_engine.douyin_im.http_client import DouyinImHttpClient from rpa_engine.douyin_im.session import DouyinImSession from utils.cookie_store import analyze_cookie logger = logging.getLogger("credential") def _should_reset_credentials(assessment: dict) -> bool: """凭证全面失效时需清空 Cookie/IM 数据并重新登录。""" if not assessment.get("has_cookie"): return False if not assessment.get("cookie_valid"): return True message = assessment.get("message") or "" # 仅缺 ticket/签名/浏览器采集 — 保留 Cookie,走浏览器补全即可 if any( token in message for token in ("ticket", "签名密钥", "浏览器模式", "web_protect") ): return False if ( not assessment.get("im_ready") and not assessment.get("can_skip_browser") and assessment.get("has_sessionid") ): return True return False def build_im_session_from_storage( storage: dict, saved_im_data: Optional[str] = None, ) -> DouyinImSession: session = DouyinImSession.from_storage_state(storage or {}) if saved_im_data: try: saved = DouyinImSession.from_dict(json.loads(saved_im_data)) # 新粘贴的 storage_state(含真实 frontier_ws_url)优先;只有它没带时才用缓存的。 if saved.ws_urls and not session.ws_urls: session.ws_urls = saved.ws_urls if saved.sdk_cert and not session.sdk_cert: session.sdk_cert = saved.sdk_cert if saved.frontier_ts_sign and not session.frontier_ts_sign: session.frontier_ts_sign = saved.frontier_ts_sign if saved.keys_str and not session.keys_str: session.keys_str = saved.keys_str if saved.web_protect_str and not session.web_protect_str: session.web_protect_str = saved.web_protect_str if saved.my_uid and not session.my_uid: session.my_uid = saved.my_uid if saved.device_id and not session.device_id: session.device_id = saved.device_id if saved.web_id and not session.web_id: session.web_id = saved.web_id if saved.conv_meta: session.conv_meta = {**saved.conv_meta, **session.conv_meta} except Exception: pass session.sanitize_ws_urls() # Keep this builder pure and non-blocking. Frontier discovery can perform # a synchronous network request (up to 15 seconds); callers that need it # already do so from validate_im_session() through asyncio.to_thread() and # the shared background-traffic limiter. Running it here made a large # batch freeze the FastAPI event loop before any limiter was acquired. return session def has_im_session_token(session: DouyinImSession) -> bool: return bool(session.cookies.get("sessionid") or session.cookies.get("sessionid_ss")) def extract_sessionid_info(session: DouyinImSession) -> dict: sessionid = session.cookies.get("sessionid") or "" sessionid_ss = session.cookies.get("sessionid_ss") or "" return { "has_sessionid": bool(sessionid or sessionid_ss), "sessionid": sessionid, "sessionid_ss": sessionid_ss, } async def build_cookie_credential_detail( cookie_data: Optional[str], im_session_data: Optional[str] = None, runtime_check: bool = True, ) -> dict: """供编辑账号页展示 IM 凭证与 sessionid 信息""" sessionid_info = { "has_sessionid": False, "sessionid": "", "sessionid_ss": "", "im_ready": False, "im_status": "未保存 Cookie", "can_skip_browser": False, "should_reset": False, } if not cookie_data: return sessionid_info try: storage = json.loads(cookie_data) session = build_im_session_from_storage(storage, im_session_data) sessionid_info.update(extract_sessionid_info(session)) except Exception: sessionid_info["im_status"] = "Cookie 格式错误" return sessionid_info if not runtime_check: if sessionid_info["has_sessionid"]: sessionid_info["im_status"] = "已检测到 sessionid(未做运行时验证)" else: sessionid_info["im_status"] = "缺少 sessionid,无法 IM 直连" return sessionid_info assessment = await assess_account_credential(cookie_data, im_session_data) sessionid_info["im_ready"] = assessment["im_ready"] sessionid_info["im_status"] = assessment["message"] sessionid_info["can_skip_browser"] = assessment["can_skip_browser"] sessionid_info["should_reset"] = assessment["should_reset"] return sessionid_info async def validate_im_session( session: DouyinImSession, _bypass_global_limit: bool = False, ) -> tuple[bool, str]: if not _bypass_global_limit: from rpa_engine.douyin_im.traffic_control import get_traffic_controller controller = get_traffic_controller() async with controller.background_slot(0, "credential validation"): return await validate_im_session(session, _bypass_global_limit=True) if not session.can_direct_im(): if not has_im_session_token(session): return False, "缺少 sessionid,无法直连 IM" return False, "Cookie 不满足 IM 直连条件" await asyncio.to_thread(ensure_frontier_ws, session) try: auth = DouyinAuth.from_im_session(session) # 优先用已持久化的 my_uid,避免每次都发起网络 query_my_uid(uid_tt 是加密串, # int() 解析必然失败而回退到网络请求;该请求偶发失败会误判为“未就绪”)。 uid = session.my_uid or auth.get_uid() if not uid: return False, "服务端未认可当前 Cookie(无法获取用户 UID)" if not auth.is_sign_ready(): return False, "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全" session.my_uid = int(uid) async with DouyinImHttpClient(session) as http: await http.get_unread_count() # 若已缓存到会话票据,优先校验其是否仍新鲜(最理想)。 if session.conv_meta: ok, reason = await http.verify_messaging_capability(auth, session.my_uid) if ok: return True, reason # 没有缓存会话票据是首次登录的正常情况:会话 ticket 会在发送时即时 # 创建/获取(resolve_conversation_meta),因此只要 Cookie + sessionid + # 签名密钥(web_protect/keys) + UID 齐全,就视为可 IM 直连托管,不必再开浏览器。 return True, "IM 凭证就绪(Cookie 与签名密钥齐全,可直连托管)" except Exception as e: logger.warning(f"IM session validation failed: {e}") return False, f"IM 运行时验证失败: {e}" async def assess_account_credential( cookie_data: Optional[str], im_session_data: Optional[str] = None, ) -> dict: cookie_info = analyze_cookie(cookie_data) result = { "has_cookie": cookie_info.get("has_cookie", False), "cookie_valid": cookie_info.get("cookie_valid", False), "cookie_status": cookie_info.get("reason", ""), "has_sessionid": False, "im_ready": False, "can_skip_browser": False, "login_mode": "browser", "message": "未保存 Cookie,需浏览器扫码登录", "should_reset": False, } if not cookie_data: return result try: storage = json.loads(cookie_data) except Exception: result["message"] = "Cookie 格式错误" result["should_reset"] = _should_reset_credentials(result) return result session = build_im_session_from_storage(storage, im_session_data) result["has_sessionid"] = has_im_session_token(session) if not cookie_info.get("cookie_valid"): result["message"] = cookie_info.get("reason") or "Cookie 无效,需重新登录" result["should_reset"] = _should_reset_credentials(result) return result if not result["has_sessionid"]: result["login_mode"] = "browser" result["message"] = "Cookie 已保存但缺少 sessionid,需浏览器刷新登录态" result["should_reset"] = _should_reset_credentials(result) return result im_ok, im_reason = await validate_im_session(session) result["im_ready"] = im_ok if im_ok: result["can_skip_browser"] = True result["login_mode"] = "im_direct" result["message"] = im_reason else: result["login_mode"] = "browser" result["message"] = im_reason result["should_reset"] = _should_reset_credentials(result) return result