Files
2026-07-23 17:56:25 +08:00

261 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import re
import time
from dataclasses import dataclass, field
from typing import Any, Optional
from urllib.parse import parse_qs, unquote, urlparse
IM_TOKEN_COOKIES = ("sessionid", "sessionid_ss")
def is_frontier_ws_url(url: str) -> bool:
"""判断是否为抖音 IM frontier 长连接地址。
真实抓包里 host 可能是 frontier-im.douyin.com,也可能是
frontierNN-normal.zijieapi.com 这类内部别名,二者都要认。
"""
if not url or "token=" not in url:
return False
return "frontier-im.douyin.com" in url or ("frontier" in url and "zijieapi.com" in url)
@dataclass
class DouyinImSession:
"""抖音 IM 直连所需会话信息(从 Cookie + 浏览器抓包获得)"""
cookies: dict = field(default_factory=dict)
ws_urls: list = field(default_factory=list)
device_id: str = ""
web_id: str = ""
user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
keys_str: str = ""
web_protect_str: str = ""
my_uid: int = 0
# my_uid 是否已用 query/user 接口核验过(采集端推断的 my_uid 可能取错 tea_cache id
uid_verified: bool = False
conv_meta: dict = field(default_factory=dict)
# 方案 A:直接复用浏览器抓到的真实 frontier 连接凭证(绕开我们自己推导 token/access_key 不准的问题)
sdk_cert: str = "" # bd-ticket-guard 客户端证书(frontier sdk_cert / HTTP client-cert
frontier_ts_sign: str = "" # 抓包得到的新鲜 ts_sign(覆盖 web_protect 里可能已过期的)
@classmethod
def from_storage_state(cls, data: dict, extra: Optional[dict] = None) -> "DouyinImSession":
extra = extra or {}
cookies = {}
cookie_items = data.get("cookies", [])
priority_names = set(IM_TOKEN_COOKIES)
def pick_best_cookie(name: str) -> str:
matches = [
c for c in cookie_items
if c.get("name") == name and c.get("value")
]
if not matches:
return ""
matches.sort(
key=lambda c: (
0 if ".douyin.com" in (c.get("domain") or "") else 1,
-len(c.get("value") or ""),
)
)
return matches[0].get("value") or ""
for item in cookie_items:
name = item.get("name")
if not name or any(c in name for c in "()[]{}'\"\n \t\\"):
continue
val = item.get("value") or ""
if name in priority_names:
best = pick_best_cookie(name)
if best:
cookies[name] = best
elif name not in cookies or len(val) > len(cookies.get(name, "")):
cookies[name] = val
device_id = extra.get("device_id") or cookies.get("device_id") or ""
web_id = extra.get("web_id") or ""
keys_str = extra.get("keys_str") or ""
web_protect_str = extra.get("web_protect_str") or ""
# my_uid 优先级:浏览器实时采集(extra) > storage_state 顶层(凭证采集工具手填/抓取)。
# 顶层 my_uid 让导入的明文数字 UID 直接生效,避免后端用加密 uid_tt 解析失败而回退联网查询。
def _as_uid(v) -> int:
try:
return int(str(v).strip())
except (TypeError, ValueError):
return 0
my_uid = _as_uid(extra.get("my_uid")) or _as_uid(data.get("my_uid"))
user_agent = str(extra.get("user_agent") or data.get("user_agent") or "").strip()
if not device_id or not web_id or not keys_str or not web_protect_str or not my_uid:
for origin in data.get("origins", []):
for entry in origin.get("localStorage", []):
name = entry.get("name", "")
value = entry.get("value", "")
if not value:
continue
if name == "security-sdk/s_sdk_crypt_sdk" and not keys_str:
keys_str = value
if name == "security-sdk/s_sdk_sign_data_key/web_protect" and not web_protect_str:
web_protect_str = value
if "tea_cache_tokens" in name and not web_id:
try:
parsed = json.loads(value)
web_id = str(
parsed.get("web_id")
or parsed.get("user_unique_id")
or ""
)
except Exception:
pass
if name == "web_runtime_security_uid" and not device_id:
if str(value or "").isdigit():
device_id = value
if "tea_cache_tokens" in name and not my_uid:
try:
parsed = json.loads(value)
uid = parsed.get("user_unique_id")
if uid and str(uid).isdigit():
my_uid = int(uid)
except Exception:
pass
if not my_uid:
for item in data.get("cookies", []):
cname = item.get("name") or ""
if cname in ("uid_tt", "uid_tt_ss") and item.get("value"):
try:
my_uid = int(item.get("value"))
break
except (TypeError, ValueError):
pass
if not device_id and my_uid:
device_id = str(my_uid)
elif not device_id and web_id:
device_id = web_id
ws_urls = list(extra.get("ws_urls") or [])
# 方案 A:凭证采集工具可携带浏览器抓到的真实 frontier 连接(含 token/sdk_cert/ts_sign)。
frontier_ws_url = str(
extra.get("frontier_ws_url") or data.get("frontier_ws_url") or ""
).strip()
sdk_cert = str(extra.get("sdk_cert") or data.get("sdk_cert") or "").strip()
frontier_ts_sign = str(
extra.get("frontier_ts_sign")
or data.get("frontier_ts_sign")
or data.get("ts_sign")
or ""
).strip()
if is_frontier_ws_url(frontier_ws_url):
# 真实抓包 URL 优先,放在最前面
ws_urls = [frontier_ws_url] + [u for u in ws_urls if u != frontier_ws_url]
# 从真实 URL 里补抽 sdk_cert / ts_sign(用户只贴了 URL 时)。
# 注意:不能用 parse_qs(它会把 + 解成空格,毁掉 base64 证书),用 unquote。
def _q(url: str, key: str) -> str:
m = re.search(rf"[?&]{re.escape(key)}=([^&\s]+)", url)
return unquote(m.group(1)) if m else ""
if not sdk_cert:
sdk_cert = _q(frontier_ws_url, "sdk_cert")
if not frontier_ts_sign:
frontier_ts_sign = _q(frontier_ws_url, "ts_sign")
return cls(
cookies=cookies,
ws_urls=ws_urls,
device_id=str(device_id or ""),
web_id=str(web_id or ""),
keys_str=keys_str,
web_protect_str=web_protect_str,
my_uid=my_uid,
user_agent=user_agent if user_agent else cls.user_agent,
sdk_cert=sdk_cert,
frontier_ts_sign=frontier_ts_sign,
)
def to_dict(self) -> dict:
return {
"cookies": self.cookies,
"ws_urls": self.ws_urls,
"device_id": self.device_id,
"web_id": self.web_id,
"user_agent": self.user_agent,
"keys_str": self.keys_str,
"web_protect_str": self.web_protect_str,
"my_uid": self.my_uid,
"conv_meta": self.conv_meta,
"sdk_cert": self.sdk_cert,
"frontier_ts_sign": self.frontier_ts_sign,
"saved_at": time.time(),
}
@classmethod
def from_dict(cls, data: dict) -> "DouyinImSession":
if not data:
return cls()
return cls(
cookies=data.get("cookies") or {},
ws_urls=data.get("ws_urls") or [],
device_id=str(data.get("device_id") or ""),
web_id=str(data.get("web_id") or ""),
user_agent=data.get("user_agent") or cls.user_agent,
keys_str=str(data.get("keys_str") or ""),
web_protect_str=str(data.get("web_protect_str") or ""),
my_uid=int(data.get("my_uid") or 0),
conv_meta=dict(data.get("conv_meta") or {}),
sdk_cert=str(data.get("sdk_cert") or ""),
frontier_ts_sign=str(data.get("frontier_ts_sign") or ""),
)
def cookie_header(self) -> str:
parts = []
for name, value in self.cookies.items():
if name and value is not None:
parts.append(f"{name}={value}")
return "; ".join(parts)
def has_login(self) -> bool:
login_keys = {
"sessionid",
"sessionid_ss",
"sid_tt",
"sid_guard",
"passport_auth_status",
"odin_tt",
}
return any(self.cookies.get(k) for k in login_keys)
def can_direct_im(self) -> bool:
"""是否具备 Cookie 直连 IM 的最低条件(无需浏览器)"""
return self.has_login() and bool(
self.cookies.get("sessionid") or self.cookies.get("sessionid_ss")
)
def frontier_ws_url(self) -> Optional[str]:
"""仅返回 IM frontier 地址,忽略浏览器抓到的 bytelink 等无关 WS。"""
for url in self.ws_urls:
if is_frontier_ws_url(url):
return url
return None
def sanitize_ws_urls(self) -> None:
self.ws_urls = [url for url in self.ws_urls if is_frontier_ws_url(url)]
def common_params(self) -> dict[str, str]:
return {
"aid": "6383",
"app_name": "douyin_web",
"device_platform": "webapp",
"channel": "channel_pc_web",
"pc_client_type": "1",
"version_code": "170400",
"version_name": "17.4.0",
"device_id": self.device_id or self.web_id or "",
"webid": self.web_id or self.device_id or "",
}