188 lines
6.3 KiB
Python
188 lines
6.3 KiB
Python
import base64
|
||
import json
|
||
import logging
|
||
from rpa_engine.egress_channels import source_bound_requests_session
|
||
from .dy_util import (
|
||
trans_cookies,
|
||
generate_msToken,
|
||
generate_a_bogus,
|
||
splice_url,
|
||
generate_webid,
|
||
normalize_client_cert,
|
||
resolve_proto_device_id,
|
||
DEFAULT_USER_AGENT,
|
||
)
|
||
|
||
logger = logging.getLogger("douyin_im.auth")
|
||
|
||
|
||
def _parse_storage_json(raw) -> dict | None:
|
||
"""Parse localStorage JSON (supports nested data wrapper)."""
|
||
if not raw:
|
||
return None
|
||
if isinstance(raw, dict):
|
||
data = raw
|
||
else:
|
||
text = str(raw).strip()
|
||
data = None
|
||
for _ in range(4):
|
||
try:
|
||
parsed = json.loads(text)
|
||
except Exception:
|
||
break
|
||
if isinstance(parsed, str):
|
||
text = parsed
|
||
continue
|
||
if isinstance(parsed, dict):
|
||
data = parsed
|
||
break
|
||
break
|
||
if not data:
|
||
return None
|
||
|
||
inner = data.get("data")
|
||
if isinstance(inner, str):
|
||
try:
|
||
inner = json.loads(inner)
|
||
except Exception:
|
||
inner = None
|
||
if isinstance(inner, dict):
|
||
return inner
|
||
return data
|
||
|
||
|
||
class DouyinAuth:
|
||
def __init__(self):
|
||
self.cookie = None
|
||
self.cookie_str = None
|
||
self.private_key = None
|
||
self.ticket = None
|
||
self.ts_sign = None
|
||
self.client_cert = None
|
||
self.ree_public_key = None
|
||
self.uid = None
|
||
self.msToken = None
|
||
self.web_id = None
|
||
self.source_ip = ""
|
||
self.user_agent = None
|
||
|
||
def perepare_auth(
|
||
self,
|
||
cookieStr: str,
|
||
web_protect_: str = "",
|
||
keys_: str = "",
|
||
user_agent: str = "",
|
||
):
|
||
self.cookie = trans_cookies(cookieStr)
|
||
self.cookie_str = cookieStr
|
||
self.msToken = self.cookie["msToken"] if "msToken" in self.cookie else generate_msToken()
|
||
self.cookie["msToken"] = self.msToken
|
||
self.cookie_str = "; ".join([f"{k}={v}" for k, v in self.cookie.items()])
|
||
web_data = _parse_storage_json(web_protect_)
|
||
if web_data:
|
||
try:
|
||
self.ticket = web_data.get("ticket") or ""
|
||
self.ts_sign = web_data.get("ts_sign") or ""
|
||
self.client_cert = web_data.get("client_cert") or ""
|
||
except Exception as e:
|
||
logger.debug(f"web_protect parse failed: {e}")
|
||
|
||
keys_data = _parse_storage_json(keys_)
|
||
if keys_data:
|
||
try:
|
||
self.private_key = keys_data.get("ec_privateKey") or keys_data.get("privateKey") or ""
|
||
if self.private_key:
|
||
self.ree_public_key = base64.b64encode(self.private_key.encode()).decode()
|
||
except Exception as e:
|
||
logger.debug(f"keys parse failed: {e}")
|
||
|
||
if user_agent:
|
||
# 让签名上下文记住调用方 UA:query_my_uid / generate_webid 等后续
|
||
# 请求会复用它,避免退回硬编码 DEFAULT_USER_AGENT 造成 UA 不一致。
|
||
self.user_agent = user_agent
|
||
|
||
def is_sign_ready(self) -> bool:
|
||
return bool(
|
||
self.private_key
|
||
and self.ticket
|
||
and self.ts_sign
|
||
and self.client_cert
|
||
)
|
||
|
||
@classmethod
|
||
def from_im_session(cls, session) -> "DouyinAuth":
|
||
"""从 DouyinImSession 构建 HTTP 发送/拉会话用的签名上下文。
|
||
|
||
注意:frontier WS 的 sdk_cert/ts_sign 只用于长连接,不能覆盖 web_protect,
|
||
否则 bd-ticket-guard 与 protobuf 签名会与 ticket 失配 -> 7911。
|
||
"""
|
||
auth = cls()
|
||
auth.perepare_auth(
|
||
session.cookie_header(),
|
||
session.web_protect_str,
|
||
session.keys_str,
|
||
user_agent=session.user_agent or DEFAULT_USER_AGENT,
|
||
)
|
||
auth.web_id = session.web_id or session.device_id or None
|
||
# device_id 是设备注册号(query/user 的 id),不是账号 UID;
|
||
# my_uid 只作为最后兜底,由 resolve_proto_device_id 内部处理。
|
||
auth.device_id = resolve_proto_device_id(
|
||
session.device_id, session.web_id, session.my_uid
|
||
)
|
||
# web_protect 缺 client_cert 时,才用 frontier 抓包证书兜底(不覆盖 ts_sign)
|
||
if not auth.client_cert and getattr(session, "sdk_cert", ""):
|
||
auth.client_cert = normalize_client_cert(session.sdk_cert)
|
||
elif auth.client_cert:
|
||
auth.client_cert = normalize_client_cert(auth.client_cert)
|
||
return auth
|
||
|
||
def get_uid(self):
|
||
if self.uid is None:
|
||
# 优先从 cookie 尝试提取,否则请求接口
|
||
for k in ("uid_tt", "uid_tt_ss"):
|
||
if self.cookie and self.cookie.get(k):
|
||
try:
|
||
self.uid = int(self.cookie.get(k))
|
||
return self.uid
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
self.uid = self.query_my_uid()
|
||
except Exception:
|
||
pass
|
||
return self.uid
|
||
|
||
def query_my_uid(self) -> int:
|
||
ua = self.user_agent or DEFAULT_USER_AGENT
|
||
url = 'https://www.douyin.com/aweme/v1/web/query/user/'
|
||
headers = {
|
||
"User-Agent": ua,
|
||
"Referer": "https://www.douyin.com/",
|
||
"Accept": "application/json, text/plain, */*",
|
||
}
|
||
params = {
|
||
"device_platform": "webapp",
|
||
"aid": "6383",
|
||
"channel": "channel_pc_web",
|
||
"publish_video_strategy_type": "2",
|
||
"verifyFp": self.cookie.get('s_v_web_id', ''),
|
||
"fp": self.cookie.get('s_v_web_id', ''),
|
||
"webid": generate_webid(self, "https://www.douyin.com/"),
|
||
"msToken": self.msToken
|
||
}
|
||
query = splice_url(params)
|
||
abogus = generate_a_bogus(query, user_agent=ua)
|
||
params['a_bogus'] = abogus
|
||
|
||
with source_bound_requests_session(self.source_ip) as client:
|
||
resp = client.get(
|
||
url,
|
||
params=params,
|
||
headers=headers,
|
||
cookies=self.cookie,
|
||
verify=False,
|
||
timeout=10,
|
||
)
|
||
resp_json = resp.json()
|
||
return int(resp_json['user_uid'])
|