Files
auv/backend/services/themes.py
T
SakurasanandClaude 548ebee47f perf: 优化热点穿透加载速度 + Canvas 渲染 + 触控板/手机交互
后端加载优化(盘中原本每次访问都重新聚合,耗时 10-30 秒):
- 图聚合结果与题材股票子层盘中加 60 秒缓存
- 缓存过期时返回旧数据并后台幂等重建(stale-while-revalidate),打开即秒开
- 缓存基础支持秒级 TTL(set_cache 新增 ttl_seconds)
- 响应体瘦身:移除 edges(前端从 stocks[].themeCodes 重建)、
  themes 精简字段、stocks 按 limit 裁剪,JSON 从数 MB 降至数百 KB

前端 Canvas 渲染重构:
- d3-force 布局保留,SVG 渲染层替换为 Canvas 双缓冲(静止态离屏层 drawImage)
- tick 由每帧 setState 改为 rAF 合帧,拖拽/缩放期间零 React 重渲染,800 节点流畅

交互优化:
- 手机:新增 +/−/适应 缩放按钮、命中半径放大至 22px、tap/drag 6px 阈值区分、
  双指捏合缩放、触屏禁用 hover 避免与选中冲突
- Mac 触控板:双指滚动=平移、捏合(ctrlKey)=缩放、滚轮缩放保留、点空白取消选中

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 13:21:08 +08:00

409 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""东方财富题材数据服务:题材列表、题材详情、题材相关股票
逆向自 emrnweb.eastmoney.com/investment 的 H5 接口:
- 题材列表: POST https://emcfgdata.eastmoney.com/api/themeInvest/getThemeList
- 题材详情: GET https://emcfgdata.securities.eastmoney.com/api/themeInvest/getDetail/{themeCode}
- 相关股票: POST https://emcfgdata.eastmoney.com/api/themeInvest/getStockList
两个 POST 接口需要「移动端包装结构」:
{args:{...业务参数}, appKey, client, clientVersion, clientType, randomCode, timestamp}
"""
import asyncio
import json
import random
import string
import time
from datetime import datetime, time as dtime, timedelta, timezone
from typing import Optional
import httpx
from services.cache import get_cache, set_cache
# ---- 东方财富移动端配置中心域名 ----
_PZ_URL = "https://emcfgdata.eastmoney.com"
_PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
# appKey 与页面场景对应:题材列表索引页 / 题材详情页
_APP_KEY_INDEX = "rn-themeIndex"
_APP_KEY_DETAIL = "rn-themeDetail"
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Origin": "https://emrnweb.eastmoney.com",
"Referer": "https://emrnweb.eastmoney.com/",
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9",
}
# ---- 交易时段感知缓存 ----
_CST = timezone(timedelta(hours=8)) # 北京时间
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
def _is_trading_time() -> bool:
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00"""
now = datetime.now(_CST)
if now.weekday() >= 5:
return False
t = now.time()
return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1]
or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1])
def _dynamic_ttl() -> int:
"""盘中返回 2 分钟缓存 TTL,非交易时段 18 小时(覆盖到下一交易日)"""
return 0 if _is_trading_time() else 18
# ---- 请求封装 ----
def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -> dict:
"""构建东方财富移动端请求包装结构"""
return {
"args": args or {},
"appKey": app_key,
"client": "iOS",
"clientVersion": "8.3",
"clientType": "cfw",
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
"timestamp": int(time.time() * 1000),
}
async def _post(path: str, args: dict, app_key: str = _APP_KEY_INDEX) -> Optional[dict]:
"""POST 到配置中心接口,返回 data 层 JSON"""
payload = _build_payload(args, app_key)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(_PZ_URL + path, json=payload, headers=_HEADERS)
if resp.status_code != 200:
return None
body = resp.json()
except Exception as e:
print(f"[themes] POST {path} 失败: {e}")
return None
if body.get("code") != 0:
print(f"[themes] POST {path} 返回错误: {body.get('message')}")
return None
return body.get("data")
async def _get_cdn(path: str, app_key: str = _APP_KEY_DETAIL) -> Optional[dict]:
"""GET 到配置中心 CDN 接口(题材详情),data 包装结构放 query 参数"""
payload = _build_payload({}, app_key)
params = {"data": json.dumps(payload, ensure_ascii=False)}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(_PZ_CDN_URL + path, params=params, headers=_HEADERS)
if resp.status_code != 200:
return None
body = resp.json()
except Exception as e:
print(f"[themes] GET {path} 失败: {e}")
return None
if body.get("code") != 0:
print(f"[themes] GET {path} 返回错误: {body.get('message')}")
return None
return body.get("data")
# ---- 题材列表 ----
# sortField 映射(题材列表页):1=涨幅(bf3) 3=强度(strengthValue) 4=热度排名(hotRank) 5=成交额(fex5)
_LIST_PAGE_SIZE = 500
async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]:
"""获取全部题材列表(内部循环分页拉全,约 623 个,最多 2 页)
Args:
sort_field: 排序字段 1/3/4/5
asc: True=升序, False=降序
"""
cache_key = f"theme_list:{sort_field}:{asc}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
sort = 1 if asc else -1
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
if sort_field == 4:
sort = -sort
items: list[dict] = []
page = 1
total = None
for _ in range(5): # 安全上限
data = await _post(
"/api/themeInvest/getThemeList",
{"pageSize": _LIST_PAGE_SIZE, "pageNum": page, "sort": sort, "sortField": sort_field},
)
if not data:
break
if total is None:
total = data.get("total", 0)
page_items = data.get("list", [])
if not page_items:
break
items.extend(page_items)
if len(items) >= total:
break
page += 1
if items:
ttl = _dynamic_ttl()
if ttl > 0:
set_cache(cache_key, json.dumps(items, ensure_ascii=False), ttl_hours=ttl)
return items
# ---- 题材详情 ----
async def fetch_theme_detail(theme_code: str) -> Optional[dict]:
"""获取题材详情(简介 + 热点事件 + 相关新闻),缓存 1 小时"""
cache_key = f"theme_detail:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _get_cdn(f"/api/themeInvest/getDetail/{theme_code}", app_key=_APP_KEY_DETAIL)
if not data:
return None
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_hours=1)
return data
# ---- 题材相关股票 ----
_STOCK_PAGE_SIZE = 100
async def fetch_theme_stocks(theme_code: str) -> dict:
"""获取题材下全部相关股票(分页拉全),返回 {stockList, statistic, total}"""
cache_key = f"theme_stocks:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
stock_list: list[dict] = []
statistic = {}
total = 0
page = 1
for _ in range(20): # 安全上限
data = await _post(
"/api/themeInvest/getStockList",
{"themeCode": theme_code, "pageSize": _STOCK_PAGE_SIZE, "pageNum": page, "sort": -1, "sortField": "f3"},
app_key=_APP_KEY_DETAIL,
)
if not data:
break
if not statistic and data.get("statistic"):
statistic = data["statistic"]
page_items = data.get("stockList", [])
if not page_items:
break
stock_list.extend(page_items)
total = data.get("total", 0)
if len(stock_list) >= total:
break
page += 1
result = {"stockList": stock_list, "statistic": statistic, "total": total}
if stock_list:
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_seconds=ttl_s)
return result
# ---- 热点穿透:题材-股票 网状关系图数据 ----
# 合并采样:涨幅榜 Top N + 热度榜 Top N 去重合并,
# 避免单一榜单导致股票覆盖题材数被低估(如有研新材覆盖 10+ 题材,仅涨幅榜只能采到 2 个)。
# 盘中图聚合结果/题材股票子层的缓存秒数。交易时段数据波动快,用 60s 短缓存;
# 非交易时段缓存 18 小时(覆盖到下一交易日)。
_GRAPH_CACHE_SECONDS = 60
def _graph_ttl_seconds() -> int:
"""图聚合结果与题材股票子层的缓存秒数:盘中 60 秒,非盘中 18 小时"""
return _GRAPH_CACHE_SECONDS if _is_trading_time() else 18 * 3600
# 后台重建锁:cache_key -> asyncio.Lock,幂等去重,防止并发重复聚合
_REBUILD_LOCKS: dict[str, asyncio.Lock] = {}
def _set_graph_cache(cache_key: str, data: dict) -> None:
"""写入图聚合缓存(存 data + built_at + expires_atepoch 秒)"""
ttl_s = _graph_ttl_seconds()
if ttl_s <= 0:
return
now = time.time()
entry = {"data": data, "built_at": now, "expires_at": now + ttl_s}
set_cache(cache_key, json.dumps(entry, ensure_ascii=False), ttl_seconds=ttl_s)
def _get_graph_cache(cache_key: str) -> tuple[Optional[dict], Optional[float]]:
"""读取图聚合缓存,返回 (data, expires_at);无缓存/损坏返回 (None, None)"""
cached = get_cache(cache_key)
if cached is None:
return None, None
try:
entry = json.loads(cached)
return entry.get("data"), entry.get("expires_at")
except (json.JSONDecodeError, TypeError):
return None, None
async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
"""构建完整图数据(全量统计,边由前端从 stocks[].themeCodes 重建)
Args:
sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充)
top_n: 每个榜单的题材数量(1-60)
Returns:
{
"themes": [{themeCode, themeName, stockCount}],
"stocks": [{securityCode, securityName, coverCount, f3, f2, f62, f100, themeCodes[]}],
"stats": {themeCount, stockCount, coreCount, maxCover}
}
"""
# 1. 合并采样涨幅榜 + 热度榜(当前榜优先在前,另一榜补充),按 themeCode 去重
primary_list = await fetch_theme_list(sort_field, asc=False)
other_field = 4 if sort_field == 1 else 1
other_list = await fetch_theme_list(other_field, asc=False)
merged: dict[str, dict] = {}
for t in primary_list[:top_n]:
merged.setdefault(t["themeCode"], t)
for t in other_list[:top_n]:
merged.setdefault(t["themeCode"], t)
themes = list(merged.values())
if not themes:
return {"themes": [], "stocks": [], "stats": {}}
# 2. 并发拉取每题材股票(限流保护)
sem = asyncio.Semaphore(5)
async def _fetch_with_limit(code: str):
async with sem:
return await fetch_theme_stocks(code)
results = await asyncio.gather(*[_fetch_with_limit(t["themeCode"]) for t in themes])
# 3. 构建 M:N 关系:统计每股覆盖的题材数
theme_map = {t["themeCode"]: t for t in themes}
stock_map: dict[str, dict] = {} # securityCode -> stock dict
for t, res in zip(themes, results):
stock_list = res.get("stockList", [])
theme_map[t["themeCode"]]["stockCount"] = len(stock_list)
for s in stock_list:
code = s["securityCode"]
if code not in stock_map:
stock_map[code] = {
"securityCode": code,
"securityName": s.get("securityName", ""),
"coverCount": 0,
"f3": s.get("f3"),
"f2": s.get("f2"),
"f62": s.get("f62"),
"f100": s.get("f100", ""),
"themeCodes": [],
}
stock_map[code]["coverCount"] += 1
stock_map[code]["themeCodes"].append(t["themeCode"])
# 4. 排序:覆盖题材数越多(穿透越强)排越前
stocks = sorted(stock_map.values(), key=lambda x: (-x["coverCount"], -(x.get("f3") or 0)))
stats = {
"themeCount": len(themes),
"stockCount": len(stocks),
"coreCount": sum(1 for s in stocks if s["coverCount"] >= 2),
"maxCover": max((s["coverCount"] for s in stocks), default=1),
}
return {
"themes": [
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]}
for t in theme_map.values()
],
"stocks": stocks,
"stats": stats,
}
def _trim_graph_result(result: dict, limit: int) -> dict:
"""按 limit 裁剪 stocks(保留穿透度最高的 N 只),仅影响下发体积,不影响 coverCount 统计"""
return {
"themes": result.get("themes", []),
"stocks": result.get("stocks", [])[:limit],
"stats": result.get("stats", {}),
}
def _spawn_rebuild(cache_key: str, sort_field: int, top_n: int) -> None:
"""幂等触发后台重建:已有重建任务在跑则跳过"""
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
if lock.locked():
return
asyncio.create_task(_rebuild_task(cache_key, sort_field, top_n, lock))
async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: asyncio.Lock) -> None:
"""后台重建:拿锁后 double-check 缓存是否已被刷新,避免重复聚合"""
async with lock:
try:
data, expires_at = _get_graph_cache(cache_key)
if data is not None and expires_at and expires_at > time.time():
return # 已被其他任务刷新
data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data)
print(f"[themes] graph 后台重建完成: {cache_key}")
except Exception as e:
print(f"[themes] graph 后台重建失败: {cache_key} {e}")
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
"""获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开);
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。
Args:
sort_field: 题材排序 1=涨幅, 4=热度
top_n: 每个榜单的题材数量(1-60)
limit: 下发 stocks 上限(穿透度最高的 N 只)
"""
cache_key = f"theme_graph:{sort_field}:{top_n}"
data, expires_at = _get_graph_cache(cache_key)
if data is not None:
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
if not (expires_at and expires_at > time.time()):
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit)
# 无缓存:同步构建(并发下加锁去重)
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
async with lock:
data, expires_at = _get_graph_cache(cache_key)
if data is not None:
# 等待锁期间已被其他请求写入
if not (expires_at and expires_at > time.time()):
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit)
data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data)
return _trim_graph_result(data, limit)