420 lines
17 KiB
Python
420 lines
17 KiB
Python
"""
|
||
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
|
||
|
||
|
||
# ---------- 主插件类 ----------
|
||
@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 <CF用户名>")
|
||
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)
|
||
return
|
||
for c in charts:
|
||
yield event.image_result(c)
|
||
return
|
||
|
||
# 没有图表时才发文本报告
|
||
yield event.plain_result(text_report)
|