Files
dy/backend/rpa_engine/douyin_im/peer_profile.py
T
2026-08-27 18:32:03 +08:00

241 lines
7.7 KiB
Python

"""私信对方用户资料抓取(昵称 / 头像 / UID)。"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Optional
from rpa_engine.device_profiles import resolve_user_agent
from rpa_engine.egress_channels import resolve_fixed_channel, source_bound_requests_session
from .auth import DouyinAuth
from .conv_util import resolve_peer_uid
from .dy_util import (
DEFAULT_USER_AGENT,
generate_a_bogus,
generate_msToken,
generate_webid,
splice_url,
)
from .protocol import _pick_avatar_url
from .session import DouyinImSession
logger = logging.getLogger("douyin_im.peer_profile")
_profile_cache: dict[str, dict[str, str]] = {}
_profile_cache_at: dict[str, float] = {}
_PROFILE_SUCCESS_TTL = 6 * 3600
_PROFILE_FAILURE_TTL = 5 * 60
def _cache_key(account_id: int, peer_uid: str) -> str:
return f"{account_id}:{peer_uid}"
def _pick_str(data: dict, *keys: str) -> str:
for key in keys:
value = data.get(key)
if value is not None and str(value).strip():
return str(value).strip()
return ""
def _extract_profile_from_payload(data: Any) -> dict[str, str]:
if not isinstance(data, dict):
return {}
nodes = [data, data.get("user"), data.get("user_info"), data.get("data")]
for node in nodes:
if not isinstance(node, dict):
continue
uid = _pick_str(node, "uid", "user_id", "user_uid", "id")
nickname = _pick_str(
node,
"nickname",
"nick_name",
"unique_id",
"display_name",
"name",
)
avatar = _pick_avatar_url(node)
if uid or nickname or avatar:
return {"uid": uid, "nickname": nickname, "avatar_url": avatar}
return {}
def _requests_proxies() -> dict | None:
try:
from rpa_engine.runtime_config import requests_proxies
return requests_proxies()
except Exception:
return None
def _build_auth(session: DouyinImSession) -> tuple[DouyinAuth, str]:
ua = resolve_user_agent(session.user_agent or DEFAULT_USER_AGENT)
auth = DouyinAuth()
auth.perepare_auth(
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=ua,
)
return auth, ua
def is_generic_peer_name(name: str, peer_uid: str = "") -> bool:
value = (name or "").strip()
if not value:
return True
if peer_uid and value == peer_uid:
return True
if value.isdigit():
return True
if value.startswith("用户") and value[2:].isdigit():
return True
if value.startswith("会话") and len(value) <= 16:
return True
return False
def fetch_peer_profile_sync(
session: DouyinImSession,
peer_uid: int | str,
account_id: int = 0,
source_ip: str = "",
) -> dict[str, str]:
uid = str(peer_uid or "").strip()
if not uid.isdigit():
return {}
cache_key = _cache_key(account_id, uid)
cached = _profile_cache.get(cache_key)
cached_at = _profile_cache_at.get(cache_key, 0.0)
if cached:
ttl = (
_PROFILE_SUCCESS_TTL
if cached.get("nickname") or cached.get("avatar_url")
else _PROFILE_FAILURE_TTL
)
if time.time() - cached_at < ttl:
return dict(cached)
result = {"uid": uid, "nickname": "", "avatar_url": ""}
try:
auth, ua = _build_auth(session)
except Exception as exc:
logger.warning(f"build auth for peer profile failed: {exc}")
_profile_cache[cache_key] = dict(result)
_profile_cache_at[cache_key] = time.time()
return result
try:
web_id = session.web_id or generate_webid(auth, "https://www.douyin.com/")
if web_id and not session.web_id:
# Reuse the homepage-derived ID for every peer on this account.
session.web_id = str(web_id)
except Exception as exc:
logger.debug(f"generate webid for peer profile failed: {exc}")
web_id = session.web_id or ""
headers = {
"User-Agent": ua,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
}
base_params = {
"device_platform": "webapp",
"aid": "6383",
"channel": "channel_pc_web",
"publish_video_strategy_type": "2",
"user_id": uid,
"sec_user_id": "",
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
"webid": web_id,
"msToken": auth.msToken or generate_msToken(),
}
endpoints = [
"https://www.douyin.com/aweme/v1/web/user/profile/other/",
"https://www.douyin.com/aweme/v1/web/im/user/info/",
]
proxies = None if source_ip else _requests_proxies()
for url in endpoints:
try:
params = dict(base_params)
query = splice_url(params)
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
with source_bound_requests_session(source_ip) as client:
resp = client.get(
url,
params=params,
headers=headers,
cookies=auth.cookie,
verify=False,
timeout=12,
proxies=proxies,
)
data = resp.json()
extracted = _extract_profile_from_payload(data)
if extracted.get("uid") and not result["uid"]:
result["uid"] = extracted["uid"]
if extracted.get("nickname") and not result["nickname"]:
result["nickname"] = extracted["nickname"]
if extracted.get("avatar_url") and not result["avatar_url"]:
result["avatar_url"] = extracted["avatar_url"]
if result["nickname"] and result["avatar_url"]:
break
except Exception as exc:
logger.debug(f"peer profile fetch failed for {url}: {exc}")
# Cache both success and failure. Without a short negative TTL, missing or
# rate-limited profiles were fetched again for every account poll.
_profile_cache[cache_key] = dict(result)
_profile_cache_at[cache_key] = time.time()
return result
async def fetch_peer_profile(
session: DouyinImSession,
peer_uid: int | str,
account_id: int = 0,
) -> dict[str, str]:
from .traffic_control import get_traffic_controller
controller = get_traffic_controller()
source_ip = str(getattr(session, "egress_source_ip", "") or "").strip()
selected_public_ip = str(getattr(session, "egress_public_ip", "") or "").strip()
if selected_public_ip and not source_ip:
try:
route = await resolve_fixed_channel(selected_public_ip)
source_ip = str(route.source_ip or "")
except Exception as exc:
logger.debug("peer profile egress resolution failed: %s", exc)
async with controller.background_slot(account_id, "peer profile"):
return await asyncio.to_thread(
fetch_peer_profile_sync,
session,
peer_uid,
account_id,
source_ip,
)
def enrich_conversation_item(conv: dict, my_uid: int = 0) -> dict:
"""补全会话项中的 peer_uid / sender_id。"""
item = dict(conv or {})
conv_id = str(item.get("conversation_id") or "").strip()
peer_uid = str(item.get("peer_uid") or item.get("sender_id") or "").strip()
if (not peer_uid or not peer_uid.isdigit()) and conv_id and my_uid:
resolved = resolve_peer_uid(conv_id, int(my_uid))
if resolved:
peer_uid = str(resolved)
if peer_uid:
item["peer_uid"] = peer_uid
item["sender_id"] = peer_uid
elif conv_id:
item["sender_id"] = conv_id
return item