"""抖音标准表情(评论区 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() _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: """若缓存为空/过期则拉取(同步阻塞,调用方建议放线程)。""" if is_fresh(): return mapping = fetch_emoji_map(session) if mapping: set_emoji_map(mapping)