Files
dy/backend/rpa_engine/douyin_im/emoji_pack.py
T
2026-07-23 17:56:25 +08:00

137 lines
4.4 KiB
Python

"""抖音标准表情(评论区 emoji)名称 -> 图片 URL 映射。
抖音文字表情如 [酷拽]/[微笑] 通过 WS 以 message_type=7 的纯文本下发,
content 形如 {"text":"[酷拽]","aweType":700},不带图片地址;
浏览器端靠本地表情表把 [name] 渲染成小图。这里拉取官方表情列表接口,
建立 name->url 映射,收到文字表情时补成可显示的贴纸。
"""
from __future__ import annotations
import logging
import re
import threading
import time
from typing import Optional
logger = logging.getLogger("douyin_im.emoji")
_EMOJI_MAP: dict[str, str] = {}
_FETCHED_AT: float = 0.0
_TTL = 6 * 3600 # 6 小时刷新一次
_lock = threading.Lock()
_fetch_lock = threading.Lock()
_LAST_FETCH_ATTEMPT: float = 0.0
_FAILURE_RETRY_SECONDS = 60.0
_BRACKET_RE = re.compile(r"^\[[^\[\]]{1,24}\]$")
def has_emoji_map() -> bool:
return bool(_EMOJI_MAP)
def is_fresh() -> bool:
return bool(_EMOJI_MAP) and (time.time() - _FETCHED_AT) < _TTL
def set_emoji_map(mapping: dict[str, str]) -> None:
global _EMOJI_MAP, _FETCHED_AT
if mapping:
with _lock:
_EMOJI_MAP = dict(mapping)
_FETCHED_AT = time.time()
def lookup_emoji_url(name: str) -> str:
"""name 可带或不带中括号,返回标准表情图片 URL(无则空串)。"""
if not name:
return ""
key = name.strip()
if not key:
return ""
if not key.startswith("["):
key = f"[{key}]"
return _EMOJI_MAP.get(key, "")
def looks_like_emoji_token(text: str) -> bool:
return bool(_BRACKET_RE.match((text or "").strip()))
def fetch_emoji_map(session) -> dict[str, str]:
"""用账号会话拉取官方表情列表,返回 {display_name: url}。失败返回 {}。"""
try:
import requests
from .auth import DouyinAuth
from .dy_util import generate_a_bogus, generate_msToken, splice_url
auth = DouyinAuth.from_im_session(session)
ua = session.user_agent
s_v_web_id = session.cookies.get("s_v_web_id", "") if session.cookies else ""
params = {
"device_platform": "webapp",
"aid": "6383",
"channel": "channel_pc_web",
"pc_client_type": "1",
"version_code": "170400",
"version_name": "17.4.0",
"cookie_enabled": "true",
"browser_language": "zh-CN",
"browser_platform": "Win32",
"browser_name": "Mozilla",
"browser_online": "true",
"verifyFp": s_v_web_id,
"fp": s_v_web_id,
"webid": session.web_id or session.device_id or "",
"msToken": generate_msToken(),
}
params["a_bogus"] = generate_a_bogus(splice_url(params), user_agent=ua)
headers = {
"User-Agent": ua,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
"Cookie": session.cookie_header(),
}
resp = requests.get(
"https://www.douyin.com/aweme/v1/web/emoji/list",
params=params,
headers=headers,
cookies=auth.cookie if getattr(auth, "cookie", None) else None,
verify=False,
timeout=20,
)
data = resp.json()
mapping: dict[str, str] = {}
for item in data.get("emoji_list") or []:
name = item.get("display_name")
urls = (item.get("emoji_url") or {}).get("url_list") or []
if name and urls:
mapping[name] = urls[0]
logger.info("Fetched %d douyin emoji", len(mapping))
return mapping
except Exception as e:
logger.warning("fetch_emoji_map failed: %s", e)
return {}
def ensure_emoji_map(session) -> None:
"""若缓存为空/过期则拉取(同步阻塞,调用方建议放线程)。"""
global _LAST_FETCH_ATTEMPT
if is_fresh():
return
# Batch-started accounts used to all observe an empty cache and fetch the
# same emoji list concurrently. Keep the network request itself inside a
# separate single-flight lock (set_emoji_map uses _lock).
with _fetch_lock:
if is_fresh():
return
now = time.time()
if now - _LAST_FETCH_ATTEMPT < _FAILURE_RETRY_SECONDS:
return
_LAST_FETCH_ATTEMPT = now
mapping = fetch_emoji_map(session)
if mapping:
set_emoji_map(mapping)