This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
"""账号凭证检测:静态 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_uiduid_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