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:
@@ -1,6 +1,6 @@
|
||||
[ValueRank]
|
||||
enable = true
|
||||
command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"]
|
||||
command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
|
||||
command-format = """
|
||||
📊 身价系统命令:
|
||||
1. 我的身价
|
||||
@@ -8,10 +8,11 @@ command-format = """
|
||||
3. 社交热度榜 [名次]
|
||||
4. 搭子榜 [名次]
|
||||
5. 社交桥梁榜 [名次]
|
||||
6. 我的趋势 [天数]
|
||||
7. 身价周报
|
||||
8. 身价说明
|
||||
9. 重算身价(管理员)
|
||||
6. 社交关系图 [人数]
|
||||
7. 我的趋势 [天数]
|
||||
8. 身价周报
|
||||
9. 身价说明
|
||||
10. 重算身价(管理员)
|
||||
"""
|
||||
|
||||
# 统计窗口(天)
|
||||
@@ -30,6 +31,9 @@ base_score_scale = 1000
|
||||
# 排行默认展示数量
|
||||
default_rank_limit = 10
|
||||
max_rank_limit = 50
|
||||
default_graph_nodes = 12
|
||||
max_graph_nodes = 24
|
||||
graph_edge_pool_limit = 300
|
||||
default_trend_days = 7
|
||||
max_trend_days = 30
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import math
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
@@ -13,6 +14,7 @@ from base.plugin_common.plugin_interface import PluginStatus
|
||||
from db.base import BaseDBOperator
|
||||
from db.connection import DBConnectionManager
|
||||
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.wechat.contact_manager import ContactManager
|
||||
|
||||
@@ -307,6 +309,23 @@ class ValueRankDB(BaseDBOperator):
|
||||
"""
|
||||
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]:
|
||||
"""读取某天群内所有用户分数字典:{user_id: score}。"""
|
||||
sql = """
|
||||
@@ -474,7 +493,7 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
"""
|
||||
|
||||
FEATURE_KEY = "VALUE_RANK"
|
||||
FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 我的趋势, 身价周报, 身价说明, 重算身价]"
|
||||
FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 社交关系图, 我的趋势, 身价周报, 身价说明, 重算身价]"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -515,10 +534,10 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
|
||||
# 配置默认值:即使未配置 config.toml,也能以保守参数运行。
|
||||
self.enable = True
|
||||
self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"]
|
||||
self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
|
||||
self.command_format = (
|
||||
"我的身价 | 身价排行 [名次] | 社交热度榜 [名次] | 搭子榜 [名次] | "
|
||||
"社交桥梁榜 [名次] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
|
||||
"社交桥梁榜 [名次] | 社交关系图 [人数] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
|
||||
)
|
||||
|
||||
self.message_window_days = 7
|
||||
@@ -535,6 +554,9 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
|
||||
self.default_rank_limit = 10
|
||||
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.max_trend_days = 30
|
||||
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.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.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))
|
||||
@@ -647,6 +672,15 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
await bot.send_text_message(roomid, text, sender)
|
||||
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 == "我的趋势":
|
||||
days = self._parse_trend_days(content)
|
||||
text = await self._build_my_trend_text(roomid, sender, days)
|
||||
@@ -1118,6 +1152,225 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
lines.append(f"趋势结论:{trend_flag} {delta:+.2f}")
|
||||
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:
|
||||
"""构建“身价周报”文本。
|
||||
|
||||
@@ -1403,6 +1656,18 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
days = max(1, 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
|
||||
def _normalize_linear(value: float, ceiling: float) -> float:
|
||||
"""线性归一化并截断到 [0, 1]。"""
|
||||
|
||||
Reference in New Issue
Block a user