From 46ee371a762308851cab02f2e7b043ac959087a2 Mon Sep 17 00:00:00 2001 From: liuwei Date: Tue, 21 Apr 2026 14:21:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(value=5Frank):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=A4=BE=E4=BA=A4=E5=85=B3=E7=B3=BB=E5=9B=BE=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=B9=B6=E6=8E=A5=E5=85=A5md2img=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增社交关系图命令,支持社交关系图 [人数] 生成群友关系图 - 基于 t_social_edges_daily 聚合边数据,构建核心节点和关系边用于可视化 - 使用 markdown2image 的 html_to_image 能力渲染 HTML/SVG 并输出图片 - 补充图谱参数配置:default_graph_nodes、max_graph_nodes、graph_edge_pool_limit --- plugins/value_rank/config.toml | 14 +- plugins/value_rank/main.py | 271 ++++++++++++++++++++++++++++++++- 2 files changed, 277 insertions(+), 8 deletions(-) diff --git a/plugins/value_rank/config.toml b/plugins/value_rank/config.toml index a8b4022..3d88fcb 100644 --- a/plugins/value_rank/config.toml +++ b/plugins/value_rank/config.toml @@ -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 diff --git a/plugins/value_rank/main.py b/plugins/value_rank/main.py index 40a3679..620b6dd 100644 --- a/plugins/value_rank/main.py +++ b/plugins/value_rank/main.py @@ -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'' + ) + + 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'' + ) + node_svg_parts.append( + f'{safe_nick}' + ) + node_svg_parts.append( + f'连接{partner_count}人 · 互动{score:.1f}' + ) + + 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""" + + + + + + + +
+
群友社交关系图
+
{group_title}
+
{summary_text}
+
+ + + {''.join(edge_svg_parts)} + {''.join(node_svg_parts)} + +
+
+ 说明:节点越大代表连接群友越多;连线越粗代表互动越强。该图仅基于 @ 关系统计,不含纯文本对话引用关系。 +
+
+ + +""" + 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]。"""