137 lines
4.4 KiB
Python
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)
|