feat(value_rank): 新增社交关系图命令并接入md2img渲染

- 新增社交关系图命令,支持社交关系图 [人数] 生成群友关系图

- 基于 t_social_edges_daily 聚合边数据,构建核心节点和关系边用于可视化

- 使用 markdown2image 的 html_to_image 能力渲染 HTML/SVG 并输出图片

- 补充图谱参数配置:default_graph_nodes、max_graph_nodes、graph_edge_pool_limit
This commit is contained in:
liuwei
2026-04-21 14:21:45 +08:00
parent 2c90bc2ebe
commit 46ee371a76
2 changed files with 277 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
[ValueRank] [ValueRank]
enable = true enable = true
command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"] command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
command-format = """ command-format = """
📊 身价系统命令: 📊 身价系统命令:
1. 我的身价 1. 我的身价
@@ -8,10 +8,11 @@ command-format = """
3. 社交热度榜 [名次] 3. 社交热度榜 [名次]
4. 搭子榜 [名次] 4. 搭子榜 [名次]
5. 社交桥梁榜 [名次] 5. 社交桥梁榜 [名次]
6. 我的趋势 [数] 6. 社交关系图 [数]
7. 身价周报 7. 我的趋势 [天数]
8. 身价说明 8. 身价周报
9. 重算身价(管理员) 9. 身价说明
10. 重算身价(管理员)
""" """
# 统计窗口(天) # 统计窗口(天)
@@ -30,6 +31,9 @@ base_score_scale = 1000
# 排行默认展示数量 # 排行默认展示数量
default_rank_limit = 10 default_rank_limit = 10
max_rank_limit = 50 max_rank_limit = 50
default_graph_nodes = 12
max_graph_nodes = 24
graph_edge_pool_limit = 300
default_trend_days = 7 default_trend_days = 7
max_trend_days = 30 max_trend_days = 30

View File

@@ -4,6 +4,7 @@ import math
import re import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from loguru import logger from loguru import logger
@@ -13,6 +14,7 @@ from base.plugin_common.plugin_interface import PluginStatus
from db.base import BaseDBOperator from db.base import BaseDBOperator
from db.connection import DBConnectionManager from db.connection import DBConnectionManager
from utils.decorator.plugin_decorators import plugin_stats_decorator from utils.decorator.plugin_decorators import plugin_stats_decorator
from utils.markdown_to_image import html_to_image
from utils.robot_cmd.robot_command import PermissionStatus, GroupBotManager from utils.robot_cmd.robot_command import PermissionStatus, GroupBotManager
from utils.wechat.contact_manager import ContactManager from utils.wechat.contact_manager import ContactManager
@@ -307,6 +309,23 @@ class ValueRankDB(BaseDBOperator):
""" """
return self.execute_query(sql, (group_id, user_id, days)) or [] return self.execute_query(sql, (group_id, user_id, days)) or []
def get_social_edges_for_graph(self, group_id: str, social_window_days: int, limit: int) -> List[Dict[str, Any]]:
"""读取用于绘制社交关系图的边数据。"""
sql = """
SELECT
from_user_id,
to_user_id,
SUM(mention_count) AS mention_count_window,
SUM(interaction_score) AS interaction_score_window
FROM t_social_edges_daily
WHERE group_id = %s
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
GROUP BY from_user_id, to_user_id
ORDER BY interaction_score_window DESC, mention_count_window DESC
LIMIT %s
"""
return self.execute_query(sql, (group_id, social_window_days, limit)) or []
def get_snapshot_score_map(self, stat_date: str, group_id: str) -> Dict[str, float]: def get_snapshot_score_map(self, stat_date: str, group_id: str) -> Dict[str, float]:
"""读取某天群内所有用户分数字典:{user_id: score}。""" """读取某天群内所有用户分数字典:{user_id: score}。"""
sql = """ sql = """
@@ -474,7 +493,7 @@ class ValueRankPlugin(MessagePluginInterface):
""" """
FEATURE_KEY = "VALUE_RANK" FEATURE_KEY = "VALUE_RANK"
FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 我的趋势, 身价周报, 身价说明, 重算身价]" FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 社交关系图, 我的趋势, 身价周报, 身价说明, 重算身价]"
@property @property
def name(self) -> str: def name(self) -> str:
@@ -515,10 +534,10 @@ class ValueRankPlugin(MessagePluginInterface):
# 配置默认值:即使未配置 config.toml也能以保守参数运行。 # 配置默认值:即使未配置 config.toml也能以保守参数运行。
self.enable = True self.enable = True
self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"] self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
self.command_format = ( self.command_format = (
"我的身价 | 身价排行 [名次] | 社交热度榜 [名次] | 搭子榜 [名次] | " "我的身价 | 身价排行 [名次] | 社交热度榜 [名次] | 搭子榜 [名次] | "
"社交桥梁榜 [名次] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价" "社交桥梁榜 [名次] | 社交关系图 [人数] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
) )
self.message_window_days = 7 self.message_window_days = 7
@@ -535,6 +554,9 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = 10 self.default_rank_limit = 10
self.max_rank_limit = 50 self.max_rank_limit = 50
self.default_graph_nodes = 12
self.max_graph_nodes = 24
self.graph_edge_pool_limit = 300
self.default_trend_days = 7 self.default_trend_days = 7
self.max_trend_days = 30 self.max_trend_days = 30
self.mention_batch_size = 200 self.mention_batch_size = 200
@@ -564,6 +586,9 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = int(cfg.get("default_rank_limit", self.default_rank_limit)) self.default_rank_limit = int(cfg.get("default_rank_limit", self.default_rank_limit))
self.max_rank_limit = int(cfg.get("max_rank_limit", self.max_rank_limit)) self.max_rank_limit = int(cfg.get("max_rank_limit", self.max_rank_limit))
self.default_graph_nodes = int(cfg.get("default_graph_nodes", self.default_graph_nodes))
self.max_graph_nodes = int(cfg.get("max_graph_nodes", self.max_graph_nodes))
self.graph_edge_pool_limit = int(cfg.get("graph_edge_pool_limit", self.graph_edge_pool_limit))
self.default_trend_days = int(cfg.get("default_trend_days", self.default_trend_days)) self.default_trend_days = int(cfg.get("default_trend_days", self.default_trend_days))
self.max_trend_days = int(cfg.get("max_trend_days", self.max_trend_days)) self.max_trend_days = int(cfg.get("max_trend_days", self.max_trend_days))
self.mention_batch_size = int(cfg.get("mention_batch_size", self.mention_batch_size)) self.mention_batch_size = int(cfg.get("mention_batch_size", self.mention_batch_size))
@@ -647,6 +672,15 @@ class ValueRankPlugin(MessagePluginInterface):
await bot.send_text_message(roomid, text, sender) await bot.send_text_message(roomid, text, sender)
return True, "查询成功" return True, "查询成功"
if command == "社交关系图":
graph_nodes = self._parse_graph_nodes(content)
image_path = await self._render_social_graph_image(roomid, graph_nodes)
if image_path:
await bot.send_image_message(roomid, Path(image_path))
return True, "查询成功"
await bot.send_text_message(roomid, "📊 近期社交关系数据不足,暂时无法绘制关系图。", sender)
return True, "数据不足"
if command == "我的趋势": if command == "我的趋势":
days = self._parse_trend_days(content) days = self._parse_trend_days(content)
text = await self._build_my_trend_text(roomid, sender, days) text = await self._build_my_trend_text(roomid, sender, days)
@@ -1118,6 +1152,225 @@ class ValueRankPlugin(MessagePluginInterface):
lines.append(f"趋势结论:{trend_flag} {delta:+.2f}") lines.append(f"趋势结论:{trend_flag} {delta:+.2f}")
return "\n".join(lines) return "\n".join(lines)
async def _render_social_graph_image(self, group_id: str, max_nodes: int) -> Optional[str]:
"""渲染群友社交关系图并返回图片路径。
设计说明:
1. 数据层使用近窗口期社交边聚合结果,避免扫全量消息;
2. 先做无向边合并,再筛选“连接度高”的核心节点,控制图复杂度;
3. 通过 markdown2image 的 HTML 截图能力输出图片,统一渲染链路。
"""
if not self.db:
return None
edge_rows = self.db.get_social_edges_for_graph(
group_id=group_id,
social_window_days=self.social_window_days,
limit=max(20, int(self.graph_edge_pool_limit)),
)
if not edge_rows:
return None
# 先将有向边规整成无向边,防止 A->B 和 B->A 分裂成两条线导致视觉噪声。
edge_map: Dict[Tuple[str, str], Dict[str, float]] = {}
partner_map: Dict[str, set] = {}
node_score_map: Dict[str, float] = {}
for row in edge_rows:
uid_a = str(row.get("from_user_id") or "").strip()
uid_b = str(row.get("to_user_id") or "").strip()
if not uid_a or not uid_b or uid_a == uid_b:
continue
a, b = (uid_a, uid_b) if uid_a < uid_b else (uid_b, uid_a)
pair_key = (a, b)
if pair_key not in edge_map:
edge_map[pair_key] = {"mention_count": 0.0, "score": 0.0}
edge_map[pair_key]["mention_count"] += float(row.get("mention_count_window") or 0.0)
edge_map[pair_key]["score"] += float(row.get("interaction_score_window") or 0.0)
partner_map.setdefault(a, set()).add(b)
partner_map.setdefault(b, set()).add(a)
node_score_map[a] = float(node_score_map.get(a, 0.0)) + float(row.get("interaction_score_window") or 0.0)
node_score_map[b] = float(node_score_map.get(b, 0.0)) + float(row.get("interaction_score_window") or 0.0)
if not edge_map:
return None
# 使用“伙伴去重数 + 互动分”混合排序选核心节点,让图既看覆盖面也看互动强度。
sorted_nodes = sorted(
list(partner_map.keys()),
key=lambda uid: (
-len(partner_map.get(uid, set())),
-float(node_score_map.get(uid, 0.0)),
uid,
),
)
selected_nodes = sorted_nodes[:max_nodes]
selected_set = set(selected_nodes)
if len(selected_nodes) < 2:
return None
selected_edges: List[Tuple[str, str, float, float]] = []
for (a, b), data in edge_map.items():
if a in selected_set and b in selected_set:
selected_edges.append((a, b, float(data.get("mention_count", 0.0)), float(data.get("score", 0.0))))
if not selected_edges:
return None
html_content = self._build_social_graph_html(group_id, selected_nodes, selected_edges, partner_map, node_score_map)
if not html_content:
return None
output_dir = Path("temp") / "value_rank"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"social_graph_{group_id}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png"
try:
await html_to_image(html_content, str(output_path))
except Exception as e:
self.LOG.error(f"[{self.name}] 社交关系图渲染失败: group={group_id}, error={e}")
return None
return str(output_path.resolve()) if output_path.exists() else None
def _build_social_graph_html(
self,
group_id: str,
selected_nodes: List[str],
selected_edges: List[Tuple[str, str, float, float]],
partner_map: Dict[str, set],
node_score_map: Dict[str, float],
) -> str:
"""构建社交关系图 HTML含 SVG 节点和边)。"""
if not selected_nodes:
return ""
import html
width = 1300
height = 900
cx, cy = width // 2, height // 2 + 10
radius = min(width, height) * 0.33
node_count = len(selected_nodes)
# 计算节点坐标:使用圆形布局,稳定且不依赖额外图算法库。
pos_map: Dict[str, Tuple[float, float]] = {}
for idx, uid in enumerate(selected_nodes):
angle = (2.0 * math.pi * idx) / max(node_count, 1)
x = cx + radius * math.cos(angle)
y = cy + radius * math.sin(angle)
pos_map[uid] = (x, y)
max_edge_score = max([edge[3] for edge in selected_edges] + [1.0])
max_partner_count = max([len(partner_map.get(uid, set())) for uid in selected_nodes] + [1])
cm = ContactManager.get_instance()
edge_svg_parts: List[str] = []
for a, b, mention_count, score in selected_edges:
ax, ay = pos_map[a]
bx, by = pos_map[b]
normalized = max(0.12, min(score / max_edge_score, 1.0))
stroke_width = 1.0 + 7.0 * normalized
opacity = 0.20 + 0.55 * normalized
edge_svg_parts.append(
f'<line x1="{ax:.1f}" y1="{ay:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />'
)
node_svg_parts: List[str] = []
for uid in selected_nodes:
x, y = pos_map[uid]
partner_count = len(partner_map.get(uid, set()))
score = float(node_score_map.get(uid, 0.0))
size_norm = max(0.15, min(partner_count / max_partner_count, 1.0))
node_radius = 18.0 + 24.0 * size_norm
nick = cm.get_group_name(group_id, uid) or uid
safe_nick = html.escape(str(nick))
node_svg_parts.append(
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{node_radius:.1f}" fill="rgba(255, 193, 7, 0.90)" '
f'stroke="rgba(255, 152, 0, 0.95)" stroke-width="2.2"></circle>'
)
node_svg_parts.append(
f'<text x="{x:.1f}" y="{y + node_radius + 22:.1f}" text-anchor="middle" '
f'font-size="19" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
)
node_svg_parts.append(
f'<text x="{x:.1f}" y="{y + node_radius + 44:.1f}" text-anchor="middle" '
f'font-size="14" fill="#6C7A96">连接{partner_count}人 · 互动{score:.1f}</text>'
)
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)
now_text = datetime.now().strftime("%Y-%m-%d %H:%M")
summary_text = (
f"统计窗口:近{self.social_window_days} 节点数:{len(selected_nodes)} "
f"关系边:{len(selected_edges)} 生成时间:{now_text}"
)
return f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<style>
body {{
margin: 0;
background: linear-gradient(135deg, #f7fbff 0%, #f2f7ff 45%, #fff8ed 100%);
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
}}
.card {{
width: {width}px;
margin: 0 auto;
padding: 26px 26px 20px 26px;
box-sizing: border-box;
}}
.title {{
font-size: 38px;
color: #1f2d46;
font-weight: 800;
letter-spacing: 1px;
}}
.subtitle {{
margin-top: 8px;
font-size: 17px;
color: #5f6f8a;
}}
.graph-wrap {{
margin-top: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(207, 221, 246, 0.9);
box-shadow: 0 8px 24px rgba(58, 82, 130, 0.12);
overflow: hidden;
}}
.legend {{
margin-top: 14px;
font-size: 14px;
color: #6f7d96;
line-height: 1.7;
}}
</style>
</head>
<body>
<div class="card">
<div class="title">群友社交关系图</div>
<div class="subtitle">{group_title}</div>
<div class="subtitle">{summary_text}</div>
<div class="graph-wrap">
<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect x="0" y="0" width="{width}" height="{height}" fill="rgba(247,251,255,0.72)"></rect>
{''.join(edge_svg_parts)}
{''.join(node_svg_parts)}
</svg>
</div>
<div class="legend">
说明:节点越大代表连接群友越多;连线越粗代表互动越强。该图仅基于 @ 关系统计,不含纯文本对话引用关系。
</div>
</div>
</body>
</html>
"""
async def _build_weekly_report_text(self, group_id: str) -> str: async def _build_weekly_report_text(self, group_id: str) -> str:
"""构建“身价周报”文本。 """构建“身价周报”文本。
@@ -1403,6 +1656,18 @@ class ValueRankPlugin(MessagePluginInterface):
days = max(1, days) days = max(1, days)
return min(days, self.max_trend_days) return min(days, self.max_trend_days)
def _parse_graph_nodes(self, content: str) -> int:
"""解析社交关系图节点数量参数。"""
parts = content.split()
if len(parts) < 2:
return self.default_graph_nodes
try:
node_count = int(parts[1])
except Exception:
return self.default_graph_nodes
node_count = max(6, node_count)
return min(node_count, self.max_graph_nodes)
@staticmethod @staticmethod
def _normalize_linear(value: float, ceiling: float) -> float: def _normalize_linear(value: float, ceiling: float) -> float:
"""线性归一化并截断到 [0, 1]。""" """线性归一化并截断到 [0, 1]。"""