""" ATRI CF 查分与刷题分析插件 📊 v1.0 查询 Codeforces 用户信息、Rating、刷题统计 支持用户绑定、可视化图表、分析报告 """ import json import os import re import time import urllib.request from collections import Counter from pathlib import Path from astrbot.api.all import * from astrbot.api.event import filter, AstrMessageEvent from astrbot.api.star import Context, Star, register # ---------- 数据存储 ---------- BIND_DIR = Path("data/plugins/astrbot_plugin_cf_query") BIND_FILE = BIND_DIR / "bindings.json" def _load_bindings() -> dict: if BIND_FILE.exists(): with open(BIND_FILE, "r", encoding="utf-8") as f: return json.load(f) return {} def _save_bindings(data: dict): BIND_DIR.mkdir(parents=True, exist_ok=True) with open(BIND_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # ---------- CF API 工具函数 ---------- def _cf_api(url: str) -> dict | None: """调用 CF API,返回 result 或 None""" req = urllib.request.Request(url, headers={"User-Agent": "ATRI-CF-Plugin/1.0"}) try: with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read()) if data.get("status") == "OK": return data["result"] return None except Exception: return None def _query_user_info(handle: str) -> dict | None: result = _cf_api(f"https://codeforces.com/api/user.info?handles={handle}") if result and len(result) > 0: return result[0] return None def _query_rating_history(handle: str) -> list: result = _cf_api(f"https://codeforces.com/api/user.rating?handle={handle}") return result if result else [] def _query_submissions(handle: str, count: int = 200) -> list: result = _cf_api( f"https://codeforces.com/api/user.status?handle={handle}&from=1&count={count}" ) return result if result else [] def _analyze_submissions(submissions: list) -> dict: """分析提交记录,返回统计信息""" total = len(submissions) ac = sum(1 for s in submissions if s.get("verdict") == "OK") wa = sum(1 for s in submissions if "WRONG_ANSWER" in s.get("verdict", "")) tle = sum(1 for s in submissions if "TIME_LIMIT_EXCEEDED" in s.get("verdict", "")) ce = sum(1 for s in submissions if "COMPILATION_ERROR" in s.get("verdict", "")) rating_counter = Counter() tag_counter = Counter() solved = set() ac_records = [] for s in submissions: if s.get("verdict") == "OK": pid = f"{s['problem'].get('contestId', '')}{s['problem']['index']}" rtg = s["problem"].get("rating", 0) if pid not in solved: solved.add(pid) rating_counter[rtg] += 1 for t in s["problem"].get("tags", []): tag_counter[t] += 1 ac_records.append({ "pid": pid, "name": s["problem"]["name"], "rating": rtg, "time": s.get("creationTimeSeconds", 0), }) ac_records.sort(key=lambda x: x["time"], reverse=True) return { "total": total, "ac": ac, "wa": wa, "tle": tle, "ce": ce, "solved_count": len(solved), "rating_dist": dict(sorted(rating_counter.items())), "tag_dist": dict(tag_counter.most_common(10)), "recent_ac": ac_records[:10], } def _generate_rating_chart(handle: str, history: list, save_path: str) -> bool: try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np except ImportError: return False if not history: return False dates, ratings, old_ratings, changes, colors = [], [], [], [], [] for c in history: ts = time.strftime("%m/%d", time.localtime(c["ratingUpdateTimeSeconds"])) dates.append(f"#{c['contestId']}\n{ts}") old_ratings.append(c["oldRating"]) ratings.append(c["newRating"]) ch = c["newRating"] - c["oldRating"] changes.append(ch) colors.append("#3eb86b" if ch >= 0 else "#e8785a") fig, ax = plt.subplots(figsize=(10, 5.5)) fig.patch.set_facecolor("#f8f4f0") ax.set_facecolor("#ffffff") x = np.arange(len(dates)) bars = ax.bar(x, ratings, width=0.5, color=colors, edgecolor="white", linewidth=1.5, zorder=3) ax.bar(x, old_ratings, width=0.5, color="#e0d0c0", alpha=0.4, edgecolor="white", linewidth=1.5, zorder=2) for i, (bar, r, ch) in enumerate(zip(bars, ratings, changes)): ax.text(bar.get_x() + bar.get_width()/2, bar.get_height()+15, str(r), ha="center", va="bottom", fontsize=13, fontweight="bold", color="#333") sign = "+" if ch > 0 else "" clr = "#3eb86b" if ch > 0 else "#e8785a" mid = old_ratings[i] + (r - old_ratings[i]) / 2 ax.annotate(f"{sign}{ch}", xy=(bar.get_x()+bar.get_width()/2, mid), fontsize=11, fontweight="bold", color="white", ha="center", va="center", bbox=dict(boxstyle="round,pad=0.2", facecolor=clr, edgecolor="none", alpha=0.85)) ax.set_xticks(x) ax.set_xticklabels(dates, fontsize=10) ax.set_title(f"{handle} · Rating Changes", fontsize=17, fontweight="bold", color="#d06040", pad=15) ax.set_ylabel("Rating", fontsize=12, fontweight="bold", color="#555") ax.set_ylim(0, max(ratings) * 1.3) for s in ["top", "right"]: ax.spines[s].set_visible(False) for s in ["left", "bottom"]: ax.spines[s].set_color("#ddd") ax.tick_params(colors="#888") ax.grid(axis="y", alpha=0.3, color="#ddd", zorder=0) plt.tight_layout() fig.savefig(save_path, dpi=180, bbox_inches="tight", facecolor="#f8f4f0") plt.close() return True def _generate_problem_chart(handle: str, rating_dist: dict, save_path: str) -> bool: try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np except ImportError: return False if not rating_dist: return False labels_map = {0: "Unrated"} labels, values, colors = [], [], [] palette = ["#b0b0b0", "#8bc34a", "#4caf50", "#26a69a", "#00bcd4", "#2196f3", "#7e57c2", "#9c27b0"] for i, (rtg, cnt) in enumerate(sorted(rating_dist.items())): labels.append(labels_map.get(rtg, str(rtg))) values.append(cnt) colors.append(palette[i % len(palette)]) fig, ax = plt.subplots(figsize=(10, 5)) fig.patch.set_facecolor("#f8f4f0") ax.set_facecolor("#ffffff") x = np.arange(len(labels)) bars = ax.bar(x, values, width=0.55, color=colors, edgecolor="white", linewidth=1.5, zorder=3) for bar, val in zip(bars, values): ax.text(bar.get_x() + bar.get_width()/2, bar.get_height()+0.1, str(val), ha="center", va="bottom", fontsize=15, fontweight="bold", color="#333") ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=12, fontweight="bold") ax.set_title(f"{handle} · Problem Ratings", fontsize=17, fontweight="bold", color="#d06040", pad=15) ax.set_ylabel("Problems Solved", fontsize=12, fontweight="bold", color="#555") ax.set_ylim(0, max(values) * 1.3) for s in ["top", "right"]: ax.spines[s].set_visible(False) for s in ["left", "bottom"]: ax.spines[s].set_color("#ddd") ax.tick_params(colors="#888") ax.grid(axis="y", alpha=0.3, color="#ddd", zorder=0) plt.tight_layout() fig.savefig(save_path, dpi=180, bbox_inches="tight", facecolor="#f8f4f0") plt.close() return True def _generate_tag_chart(handle: str, tag_dist: dict, save_path: str) -> bool: try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt except ImportError: return False if not tag_dist: return False labels = list(tag_dist.keys()) values = list(tag_dist.values()) colors = ["#e8785a", "#f0a030", "#4a90d9", "#3eb86b", "#9b59b6", "#1abc9c", "#e74c3c", "#3498db", "#f39c12", "#2ecc71", "#95a5a6"] fig, ax = plt.subplots(figsize=(8, 6)) fig.patch.set_facecolor("#f8f4f0") wedges, texts, autotexts = ax.pie(values, labels=None, autopct="%1.0f%%", startangle=90, pctdistance=0.78, colors=colors[:len(labels)], wedgeprops=dict(width=0.4, edgecolor="white", linewidth=2), textprops=dict(fontsize=11, fontweight="bold")) ax.set_title(f"{handle} · Tags", fontsize=17, fontweight="bold", color="#d06040", pad=20) ax.legend(wedges, [f"{l} ({v})" for l, v in zip(labels, values)], title="Tags", title_fontsize=12, loc="center left", bbox_to_anchor=(1, 0.5), fontsize=10, framealpha=0.9) plt.tight_layout(rect=[0, 0.03, 0.75, 0.97]) fig.savefig(save_path, dpi=180, bbox_inches="tight", facecolor="#f8f4f0") plt.close() return True def _combine_charts(paths: list, save_path: str) -> bool: try: from PIL import Image except ImportError: return False imgs = [Image.open(p) for p in paths if os.path.exists(p)] if not imgs: return False width = max(img.width for img in imgs) resized = [] for img in imgs: ratio = width / img.width resized.append(img.resize((width, int(img.height * ratio)), Image.LANCZOS)) total_h = sum(img.height for img in resized) + 10 * (len(resized) - 1) combined = Image.new("RGB", (width, total_h), "#f8f4f0") y = 0 for img in resized: combined.paste(img, (0, y)) y += img.height + 10 combined.save(save_path, quality=92) return True def _build_text_report(handle: str, user_info: dict, history: list, analysis: dict) -> str: """构建文本分析报告""" rating = user_info.get("rating", 0) max_r = user_info.get("maxRating", 0) rank = user_info.get("rank", "unrated").title() wins = sum(1 for c in history if c["newRating"] > c["oldRating"]) losses = sum(1 for c in history if c["newRating"] < c["oldRating"]) best_rank = min((c["rank"] for c in history), default="-") s = analysis ac_rate = f"{s['ac'] / s['total'] * 100:.1f}%" if s["total"] > 0 else "-" lines = [ f"📊 CF Profile: {handle}", f"━━━━━━━━━━━━━━━━━━━━", f"Rating: {rating} ({rank.title()})", f"最高: {max_r} ({user_info.get('maxRank', 'unrated').title()})", f"参赛: {len(history)}场 | 涨{wins}掉{losses}", f"最佳排名: #{best_rank}", f"━━━━━━━━━━━━━━━━━━━━", f"📝 刷题统计", f"提交: {s['total']} | AC: {s['ac']} | AC率: {ac_rate}", f"通过不同题目: {s['solved_count']}道", ] if s["rating_dist"]: lines.append("━━━━━━━━━━━━━━━━━━━━") lines.append("🎯 难度分布:") for r, c in sorted(s["rating_dist"].items()): lbl = "Unrated" if r == 0 else str(r) lines.append(f" {lbl:>7}: {'█'*c} {c}") if s["recent_ac"]: lines.append("━━━━━━━━━━━━━━━━━━━━") lines.append("🔥 最近AC:") for a in s["recent_ac"][:5]: rtg = f"[{a['rating']}]" if a["rating"] else "[?]" lines.append(f" ✅ {a['pid']} {rtg} {a['name']}") return " ".join(lines) def _build_analysis_prompt(handle: str, user_info: dict, history: list, analysis: dict) -> str: """构建AI分析用的prompt""" rating = user_info.get("rating", 0) max_r = user_info.get("maxRating", 0) rank = user_info.get("rank", "unrated").title() total_contests = len(history) wins = sum(1 for c in history if c["newRating"] > c["oldRating"]) losses = sum(1 for c in history if c["newRating"] < c["oldRating"]) best_rank = min((c["rank"] for c in history), default="无") ac_rate = f"{analysis['ac']/analysis['total']*100:.1f}%" if analysis["total"] > 0 else "无数据" recent = analysis["recent_ac"][:5] recent_str = " ".join(f" - {a['pid']} [{a['rating']}] {a['name']}" for a in recent) if recent else " 无" dist_parts = [] for r, c in sorted(analysis["rating_dist"].items()): lbl = "Unrated" if r == 0 else str(r) dist_parts.append(f"{lbl}: {c}题") dist_str = ", ".join(dist_parts) return f"""请根据以下Codeforces用户数据,生成一段简短、有温度的分析与建议(50~100字,中文,语气像朋友聊天一样自然,不要过于正式): 用户:{handle} 当前Rating:{rating}({rank}) 最高Rating:{max_r} 参赛:{total_contests}场(涨{wins}掉{losses}) 最佳排名:#{best_rank} AC率:{ac_rate} 通过题目数:{analysis['solved_count']}道 难度分布:{dist_str} 最近AC: {recent_str} 请从以下角度给出简短分析: 1. 当前水平总结(一句话) 2. 优势和不足 3. 后续练习建议""" # ---------- 主插件类 ---------- @register("astrbot_plugin_cf_query", "ATRI (YHN-04B-009)", "CF 查分与刷题分析,支持绑定、图表、报告", "1.0.0", "https://gitea.kronecker.cc/Kronecker/ATRI-NOTES") class CFQueryPlugin(Star): def __init__(self, context: Context, config: dict = None): super().__init__(context, config) self.config = config or {} self.temp_dir = Path("/AstrBot/data/temp") # ========== 绑定 ========== @filter.command("cfbind") async def cf_bind(self, event: AstrMessageEvent, handle: str = None): """绑定CF账号:cfbind <用户名>""" if not handle: yield event.plain_result("❌ 用法: cfbind ") return handle = handle.strip() info = _query_user_info(handle) if not info: yield event.plain_result(f"❌ CF用户 {handle} 不存在,请检查用户名") return uid = str(event.get_sender_id()) bindings = _load_bindings() bindings[uid] = { "handle": handle, "bind_time": int(time.time()), "rating": info.get("rating", 0), } _save_bindings(bindings) rating = info.get("rating", 0) rank = info.get("rank", "unrated").title() yield event.plain_result(f"✅ 绑定成功!\n{handle} | Rating: {rating} ({rank})") @filter.command("cfunbind") async def cf_unbind(self, event: AstrMessageEvent): """解绑CF账号""" uid = str(event.get_sender_id()) bindings = _load_bindings() if uid not in bindings: yield event.plain_result("❌ 你还没有绑定CF账号") return yield event.plain_result(f"✅ 已解绑 {bindings[uid]['handle']}") del bindings[uid] _save_bindings(bindings) @filter.command("cfwho") async def cf_who(self, event: AstrMessageEvent): """查看绑定信息""" uid = str(event.get_sender_id()) bindings = _load_bindings() if uid not in bindings: yield event.plain_result("❌ 你还没有绑定CF账号,使用 cfbind <用户名> 绑定") return info = bindings[uid] t = time.strftime('%Y-%m-%d', time.localtime(info['bind_time'])) yield event.plain_result(f"🔗 已绑定: {info['handle']} ({t})") @filter.command("cftop") async def cf_top(self, event: AstrMessageEvent): """群内CF排行榜""" bindings = _load_bindings() if not bindings: yield event.plain_result("📭 还没有人绑定CF账号") return users = [] for uid, info in bindings.items(): handle = info["handle"] fresh = _query_user_info(handle) rating = fresh.get("rating", 0) if fresh else info.get("rating", 0) users.append((handle, rating)) time.sleep(0.5) users.sort(key=lambda x: x[1], reverse=True) lines = ["🏆 群内CF排行榜", "━━━━━━━━━━━━━━"] emojis = ["🥇", "🥈", "🥉"] for i, (handle, rating) in enumerate(users[:10]): emo = emojis[i] if i < 3 else f"{i+1}." lines.append(f"{emo} {handle} — {rating}") yield event.plain_result("\n".join(lines)) # ========== 主查询 ========== @filter.command("cf") async def cf_query(self, event: AstrMessageEvent, handle: str = None): """查询CF用户:cf <用户名> 或 cf(查已绑定)""" if not handle: uid = str(event.get_sender_id()) bindings = _load_bindings() if uid in bindings: handle = bindings[uid]["handle"] if not handle: yield event.plain_result("❌ 用法: cf <用户名> 或先用 cfbind 绑定后直接 cf") return yield event.plain_result(f"🔍 正在查询 {handle} 的数据...") user_info = _query_user_info(handle) if not user_info: yield event.plain_result(f"❌ 查询失败,用户 {handle} 不存在或网络异常") return history = _query_rating_history(handle) submissions = _query_submissions(handle, 200) analysis = _analyze_submissions(submissions) # --- 文本报告 --- rating = user_info.get("rating", 0) max_r = user_info.get("maxRating", 0) rank = user_info.get("rank", "unrated").title() wins = sum(1 for c in history if c["newRating"] > c["oldRating"]) losses = sum(1 for c in history if c["newRating"] < c["oldRating"]) best_rank = min((c["rank"] for c in history), default="-") ac_rate = f"{analysis['ac']/analysis['total']*100:.1f}%" if analysis["total"] > 0 else "-" lines = [ f"📊 CF Profile: {handle}", f"━━━━━━━━━━━━━━━━━━━━", f"Rating: {rating} ({rank}) | 最高: {max_r}", f"参赛: {len(history)}场 | 涨{wins}掉{losses} | 最佳排名: #{best_rank}", f"━━━━━━━━━━━━━━━━━━━━", f"提交: {analysis['total']} | AC: {analysis['ac']} | AC率: {ac_rate}", f"通过不同题目: {analysis['solved_count']}道", ] if analysis["rating_dist"]: lines.append("━━━━━━━━━━━━━━━━━━━━") lines.append("🎯 难度分布:") for r, c in sorted(analysis["rating_dist"].items()): lbl = "Unrated" if r == 0 else str(r) lines.append(f" {lbl:>7}: {'█'*c} {c}") if analysis["recent_ac"]: lines.append("━━━━━━━━━━━━━━━━━━━━") lines.append("🔥 最近AC:") for a in analysis["recent_ac"][:5]: rtg = f"[{a['rating']}]" if a["rating"] else "[?]" lines.append(f" ✅ {a['pid']} {rtg} {a['name']}") text_report = "\n".join(lines) # --- 图表 --- ts = int(time.time()) charts = [] rc = str(self.temp_dir / f"cf_rating_{handle}_{ts}.png") if _generate_rating_chart(handle, history, rc): charts.append(rc) pc = str(self.temp_dir / f"cf_problems_{handle}_{ts}.png") if _generate_problem_chart(handle, analysis["rating_dist"], pc): charts.append(pc) tc = str(self.temp_dir / f"cf_tags_{handle}_{ts}.png") if _generate_tag_chart(handle, analysis["tag_dist"], tc): charts.append(tc) style = self.config.get("default_chart_style", "combined") if charts: # 先发图表 if style == "combined" and len(charts) >= 2: combined = str(self.temp_dir / f"cf_combined_{handle}_{ts}.png") if _combine_charts(charts, combined): yield event.image_result(combined) else: for c in charts: yield event.image_result(c) # 再用AI生成分析文本 try: analysis_prompt = _build_analysis_prompt(handle, user_info, history, analysis) provider_id = self.context.get_current_chat_provider_id(event.unified_msg_origin) llm_resp = await self.context.llm_generate( chat_provider_id=provider_id, prompt=analysis_prompt, ) if llm_resp and llm_resp.completion_text: yield event.plain_result(llm_resp.completion_text) except Exception as e: # AI分析失败时打印错误但不影响主流程 yield event.plain_result(f"⚠️ AI分析异常: {type(e).__name__}: {e}") return # 没有图表时才发文本报告 yield event.plain_result(text_report)