236 lines
7.7 KiB
Python
236 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]:
|
|
auth = DouyinAuth()
|
|
auth.perepare_auth(session.cookie_header(), session.web_protect_str, session.keys_str)
|
|
ua = resolve_user_agent(session.user_agent or DEFAULT_USER_AGENT)
|
|
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
|