nonebot-plugin-b50-analysis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+
5
+ from nonebot import get_plugin_config, on_command
6
+ from nonebot.adapters.onebot.v11 import Message, MessageEvent, MessageSegment
7
+ from nonebot.matcher import Matcher
8
+ from nonebot.params import CommandArg
9
+ from nonebot.plugin import PluginMetadata
10
+
11
+ from .config import Config
12
+ from .context_builder import build_context, load_peer_stats
13
+ from .fetch import fetch_b50
14
+ from .llm import generate_analysis
15
+ from .render import render_image
16
+
17
+ __plugin_meta__ = PluginMetadata(
18
+ name="B50分析",
19
+ description="舞萌DX B50数据分析,生成锐评文本和分析图",
20
+ homepage="https://github.com/HanYaaaaaaa/nonebot-plugin-b50-analysis",
21
+ usage=(
22
+ "分析b50 —— 默认风格分析自己的B50\n"
23
+ "分析b50 [风格/需求] —— 用指定风格或需求分析自己的B50\n"
24
+ "示例:分析b50 用可爱的语气"
25
+ ),
26
+ type="application",
27
+ config=Config,
28
+ supported_adapters={"~onebot.v11"},
29
+ )
30
+
31
+ _cfg = get_plugin_config(Config)
32
+ _peer_stats = load_peer_stats(_cfg.b50_assets_path)
33
+
34
+ b50_cmd = on_command(
35
+ "分析b50",
36
+ aliases={"b50分析", "分析B50", "B50分析"},
37
+ priority=5,
38
+ block=True,
39
+ )
40
+
41
+
42
+ @b50_cmd.handle()
43
+ async def _handle(matcher: Matcher, event: MessageEvent, args: Message = CommandArg()):
44
+ style = args.extract_plain_text().strip()
45
+ qq = event.get_user_id()
46
+
47
+ await matcher.send("正在查询 B50,请稍候…")
48
+
49
+ try:
50
+ b50_data = await fetch_b50(qq)
51
+ except ValueError as e:
52
+ await matcher.finish(str(e))
53
+ return
54
+ except Exception:
55
+ await matcher.finish("查询失败,请稍后重试")
56
+ return
57
+
58
+ b50_data["_assets_path"] = _cfg.b50_assets_path
59
+ context = build_context(b50_data, _peer_stats)
60
+ # 把实际使用的 QQ 写回 player,供头像拉取使用
61
+ context["player"]["qq"] = qq
62
+
63
+ if not _cfg.b50_llm_key:
64
+ await matcher.finish("未配置 b50_llm_key,请在 .env 中填写 API Key")
65
+ return
66
+
67
+ try:
68
+ analysis_text = await generate_analysis(context, _cfg, style)
69
+ except Exception as e:
70
+ await matcher.finish(f"分析生成失败:{e}")
71
+ return
72
+
73
+ try:
74
+ img = render_image(context, analysis_text, _cfg.b50_assets_path)
75
+ except Exception as e:
76
+ await matcher.finish(f"制图失败:{e}")
77
+ return
78
+
79
+ buf = io.BytesIO()
80
+ img.save(buf, format="PNG")
81
+ buf.seek(0)
82
+ await matcher.finish(MessageSegment.image(buf))
@@ -0,0 +1,17 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Config(BaseModel):
5
+ """从 .env / .env.prod 读取的插件配置。"""
6
+
7
+ b50_llm_url: str = "https://bitexingai.com/v1"
8
+ """OpenAI 兼容 API 的 base URL,末尾不带斜杠;推荐 Gemini 兼容入口"""
9
+
10
+ b50_llm_key: str = ""
11
+ """API Key"""
12
+
13
+ b50_llm_model: str = "gemini-3-flash-preview"
14
+ """使用的模型名称"""
15
+
16
+ b50_assets_path: str = ""
17
+ """assets 目录路径,包含 ui/fonts、ui/icons、peer_stats.zip 等素材(必填)"""
@@ -0,0 +1,395 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import json
5
+ import zipfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ FC_LABEL_MAP = {"fc": "FC", "fcp": "FC+", "ap": "AP", "app": "AP+"}
10
+
11
+
12
+ def _f(v: Any, d: float = 0.0) -> float:
13
+ try:
14
+ return float(v)
15
+ except (TypeError, ValueError):
16
+ return d
17
+
18
+
19
+ def _i(v: Any, d: int = 0) -> int:
20
+ try:
21
+ return int(v)
22
+ except (TypeError, ValueError):
23
+ return d
24
+
25
+
26
+ def _load_file(p: Path) -> dict | None:
27
+ try:
28
+ if p.suffix == ".zip":
29
+ with zipfile.ZipFile(p) as zf:
30
+ name = next(n for n in zf.namelist() if n.endswith(".json"))
31
+ return json.loads(zf.read(name))
32
+ if p.suffix == ".gz":
33
+ with gzip.open(p) as f:
34
+ return json.loads(f.read())
35
+ return json.loads(p.read_text(encoding="utf-8"))
36
+ except Exception:
37
+ return None
38
+
39
+
40
+ def _load_json(assets: Path, *parts: str) -> dict | list | None:
41
+ p = assets.joinpath(*parts)
42
+ if not p.exists():
43
+ return None
44
+ return _load_file(p)
45
+
46
+
47
+ def load_peer_stats(assets_path: str) -> dict | None:
48
+ """从 assets 目录自动查找 peer_stats 文件。"""
49
+ if not assets_path:
50
+ return None
51
+ assets = Path(assets_path)
52
+ for name in ("peer_stats.zip", "peer_stats.json.gz", "peer_stats.json"):
53
+ p = assets / name
54
+ if p.exists():
55
+ return _load_file(p)
56
+ return None
57
+
58
+
59
+ def _normalize(chart: dict) -> dict:
60
+ c = dict(chart)
61
+ c["music_id"] = str(c.get("song_id") or c.get("music_id") or "")
62
+ c["achievement"] = _f(c.get("achievements") or c.get("achievement"))
63
+ c["fc_label"] = FC_LABEL_MAP.get(str(c.get("fc") or "").lower(), "")
64
+ return c
65
+
66
+
67
+ def _fine_rating_segment(rating: int) -> dict:
68
+ if rating >= 16500:
69
+ return {
70
+ "label": "16500+ 顶级门槛段",
71
+ "range": "16500+",
72
+ "tone": "按顶段尺度评价,不要按普通 w6 轻描淡写。",
73
+ }
74
+ if rating >= 15000:
75
+ start = (rating // 200) * 200
76
+ return {
77
+ "label": f"{start}-{start + 199} 细分段",
78
+ "range": f"{start}-{start + 199}",
79
+ "tone": "按 200 分细分段评价,不要只粗暴说 w5/w6。",
80
+ }
81
+ if rating >= 13500:
82
+ start = (rating // 200) * 200
83
+ return {
84
+ "label": f"{start}-{start + 199} 上升段",
85
+ "range": f"{start}-{start + 199}",
86
+ "tone": "按 200 分细分段评价,重点看基本盘和推分空间。",
87
+ }
88
+ return {"label": "入门-进阶段", "range": "<13500", "tone": "以基础能力和推分空间为主。"}
89
+
90
+
91
+ def _ds_class(ds: float) -> str:
92
+ if ds >= 14.6:
93
+ return "14+"
94
+ if ds >= 14.0:
95
+ return "14"
96
+ if ds >= 13.6:
97
+ return "13+"
98
+ if ds >= 13.0:
99
+ return "13"
100
+ return "<13"
101
+
102
+
103
+ def _gap_tier(gap: float | None) -> str:
104
+ if gap is None:
105
+ return ""
106
+ if gap > 0.8:
107
+ return "异常领先"
108
+ if gap >= 0.5:
109
+ return "明显领先"
110
+ if gap < -0.8:
111
+ return "异常落后"
112
+ if gap <= -0.5:
113
+ return "明显落后"
114
+ return ""
115
+
116
+
117
+ def _song_evidence_row(chart: dict, chart_summaries: dict | None, rank: int) -> dict:
118
+ gap = chart.get("gap")
119
+ avg_achievement = chart.get("peer_avg")
120
+ ds = _f(chart.get("ds"))
121
+ achievement = _f(chart.get("achievement"))
122
+ summary = (chart_summaries or {}).get(str(chart.get("music_id") or "")) or {}
123
+ row = {
124
+ "rank": rank,
125
+ "music_id": str(chart.get("music_id") or ""),
126
+ "title": str(chart.get("title") or ""),
127
+ "bucket": chart.get("bucket"),
128
+ "chart_type": chart.get("type") or chart.get("chart_type"),
129
+ "level_label": chart.get("level_label"),
130
+ "ds": ds,
131
+ "ds_class": _ds_class(ds),
132
+ "achievement": round(achievement, 4),
133
+ "avg_achievement": round(_f(avg_achievement), 4) if avg_achievement is not None else None,
134
+ "peer_avg": round(_f(avg_achievement), 4) if avg_achievement is not None else None,
135
+ "gap": round(_f(gap), 4) if gap is not None else None,
136
+ "gap_vs_peer": round(_f(gap), 4) if gap is not None else None,
137
+ "gap_tier": _gap_tier(_f(gap)) if gap is not None else "",
138
+ "song_rating": _i(chart.get("ra")),
139
+ "fc_label": str(chart.get("fc_label") or ""),
140
+ "is_ap": str(chart.get("fc_label") or "").upper() in {"AP", "AP+"},
141
+ "config_tags": [str(x) for x in (summary.get("config_tags") or [])[:5]],
142
+ "is_theory": achievement >= 101.0,
143
+ "is_ap_target_reasonable": achievement >= 100.8,
144
+ "overlap": chart.get("overlap"),
145
+ "peer_sample_count": chart.get("peer_sample_count"),
146
+ }
147
+ return {k: v for k, v in row.items() if v not in (None, "", [])}
148
+
149
+
150
+ def _unique_rows(rows: list[dict], limit: int) -> list[dict]:
151
+ seen: set[tuple[str, str]] = set()
152
+ result: list[dict] = []
153
+ for row in rows:
154
+ key = (str(row.get("music_id") or ""), str(row.get("level_label") or ""))
155
+ if key in seen:
156
+ continue
157
+ seen.add(key)
158
+ result.append(row)
159
+ if len(result) >= limit:
160
+ break
161
+ return result
162
+
163
+
164
+ def _section_summary(rows: list[dict], label: str) -> dict:
165
+ gaps = [_f(r.get("gap_vs_peer")) for r in rows if r.get("gap_vs_peer") is not None]
166
+ peers = [_f(r.get("avg_achievement")) for r in rows if r.get("avg_achievement") is not None]
167
+ by_rating_desc = sorted(rows, key=lambda r: _i(r.get("song_rating")), reverse=True)
168
+ by_rating_asc = sorted(rows, key=lambda r: _i(r.get("song_rating")))
169
+ by_gap = sorted([r for r in rows if r.get("gap_vs_peer") is not None], key=lambda r: _f(r.get("gap_vs_peer")), reverse=True)
170
+ return {
171
+ "label": label,
172
+ "count": len(rows),
173
+ "role": "旧版本/历史 best 35,看基本盘、下限和长期结构" if label == "B35" else "当前版本/new best 15,看新版本适应、上限突破和近期推分效率",
174
+ "avg_ds": round(sum(_f(r.get("ds")) for r in rows if _f(r.get("ds")) > 0) / len([r for r in rows if _f(r.get("ds")) > 0]), 2) if any(_f(r.get("ds")) > 0 for r in rows) else None,
175
+ "avg_achievement": round(sum(_f(r.get("achievement")) for r in rows if _f(r.get("achievement")) > 0) / len([r for r in rows if _f(r.get("achievement")) > 0]), 4) if any(_f(r.get("achievement")) > 0 for r in rows) else None,
176
+ "avg_peer_achievement": round(sum(peers) / len(peers), 4) if peers else None,
177
+ "avg_gap_vs_peer": round(sum(gaps) / len(gaps), 4) if gaps else None,
178
+ "avg_song_rating": round(sum(_f(r.get("song_rating")) for r in rows if _f(r.get("song_rating")) > 0) / len([r for r in rows if _f(r.get("song_rating")) > 0]), 1) if any(_f(r.get("song_rating")) > 0 for r in rows) else None,
179
+ "top_cards": by_rating_desc[:5],
180
+ "floor_cards": by_rating_asc[:5],
181
+ "best_peer_gaps": by_gap[:4],
182
+ "worst_peer_gaps": list(reversed(by_gap[-4:])),
183
+ }
184
+
185
+
186
+ def _build_b50_evidence_pack(charts: list[dict], rating: int, peer_data: dict, chart_summaries: dict | None = None) -> dict:
187
+ rows = [_song_evidence_row(c, chart_summaries, idx + 1) for idx, c in enumerate(charts)]
188
+ rows_by_rating = sorted(rows, key=lambda r: _i(r.get("song_rating")), reverse=True)
189
+ rows_with_gap = sorted([r for r in rows if r.get("gap_vs_peer") is not None], key=lambda r: _f(r.get("gap_vs_peer")), reverse=True)
190
+ b35 = [r for r in rows if r.get("bucket") == "B35"]
191
+ b15 = [r for r in rows if r.get("bucket") == "B15"]
192
+ ds_bands: dict[str, list[dict]] = {}
193
+ for row in rows:
194
+ ds_bands.setdefault(str(row.get("ds_class") or "<13"), []).append(row)
195
+
196
+ ds_summary = {
197
+ band: {
198
+ "count": len(items),
199
+ "avg_achievement": round(sum(_f(x.get("achievement")) for x in items if _f(x.get("achievement")) > 0) / len([x for x in items if _f(x.get("achievement")) > 0]), 4) if any(_f(x.get("achievement")) > 0 for x in items) else None,
200
+ "avg_peer_achievement": round(sum(_f(x.get("avg_achievement")) for x in items if x.get("avg_achievement") is not None) / len([x for x in items if x.get("avg_achievement") is not None]), 4) if any(x.get("avg_achievement") is not None for x in items) else None,
201
+ "avg_gap_vs_peer": round(sum(_f(x.get("gap_vs_peer")) for x in items if x.get("gap_vs_peer") is not None) / len([x for x in items if x.get("gap_vs_peer") is not None]), 4) if any(x.get("gap_vs_peer") is not None for x in items) else None,
202
+ "avg_song_rating": round(sum(_f(x.get("song_rating")) for x in items if _f(x.get("song_rating")) > 0) / len([x for x in items if _f(x.get("song_rating")) > 0]), 1) if any(_f(x.get("song_rating")) > 0 for x in items) else None,
203
+ }
204
+ for band, items in ds_bands.items()
205
+ }
206
+
207
+ strongest = rows_with_gap[:8]
208
+ weakest = list(reversed(rows_with_gap[-8:]))
209
+ selected = _unique_rows(strongest[:4] + weakest[:4] + rows_by_rating[:4], 10)
210
+ entry_points = _unique_rows(strongest[:6] + weakest[:6], 10)
211
+
212
+ return {
213
+ "peer_comparison": {
214
+ "available": bool(peer_data),
215
+ "rating_bucket": peer_data.get("bucket"),
216
+ "matched": peer_data.get("matched", 0),
217
+ "ARPI": peer_data.get("arpi"),
218
+ "b50_overlap": peer_data.get("b50_overlap") or {},
219
+ "rule": "peer_avg/avg_achievement 是同 rating 桶玩家在同一谱同一难度的平均达成率;gap_vs_peer=当前达成率-peer_avg;ARPI 是所有可匹配 B50 谱面的平均 gap。",
220
+ },
221
+ "rating_split": {
222
+ "total": rating,
223
+ "fine_segment": _fine_rating_segment(rating),
224
+ "b35_ra": sum(_i(r.get("song_rating")) for r in b35),
225
+ "b15_ra": sum(_i(r.get("song_rating")) for r in b15),
226
+ "top10_avg_song_rating": round(sum(_f(r.get("song_rating")) for r in rows_by_rating[:10]) / len(rows_by_rating[:10]), 1) if rows_by_rating[:10] else None,
227
+ "bottom10_avg_song_rating": round(sum(_f(r.get("song_rating")) for r in sorted(rows, key=lambda r: _i(r.get("song_rating")))[:10]) / len(sorted(rows, key=lambda r: _i(r.get("song_rating")))[:10]), 1) if rows else None,
228
+ },
229
+ "b35_b15_structure": {
230
+ "rule": "B35 是旧版本/历史 best 35,主要看基本盘、下限、长期结构;B15 是当前版本/new best 15,主要看新版本适应、上限突破、近期推分效率。",
231
+ "b35": _section_summary(b35, "B35"),
232
+ "b15": _section_summary(b15, "B15"),
233
+ },
234
+ "ds_band_summary": ds_summary,
235
+ "config_focus": _build_config_focus(rows),
236
+ "same_rating_average_entry_points": entry_points,
237
+ "selected_evidence": selected,
238
+ "strongest_vs_peer": strongest,
239
+ "weakest_vs_peer": weakest,
240
+ "abnormal_peer_gaps": [r for r in rows_with_gap if str(r.get("gap_tier") or "").startswith("异常")][:8],
241
+ "highest_song_rating": rows_by_rating[:8],
242
+ "b50_floor": sorted(rows, key=lambda r: _i(r.get("song_rating")))[:8],
243
+ "theory_cards": [r for r in rows_by_rating if r.get("is_theory")][:8],
244
+ "impossible_15_theory": [r for r in rows_by_rating if _f(r.get("ds")) >= 15.0 and r.get("is_theory")][:4],
245
+ "high_ds_ap": [r for r in rows_by_rating if r.get("is_ap") and _f(r.get("ds")) >= 14.0][:8],
246
+ "level_14_plus_ap": [r for r in rows_by_rating if r.get("is_ap") and _f(r.get("ds")) >= 14.6][:6],
247
+ "mid_ds_high_gap": [r for r in rows_by_rating if 13.0 <= _f(r.get("ds")) < 14.6 and _f(r.get("gap_vs_peer")) >= 0.25][:8],
248
+ }
249
+
250
+
251
+
252
+
253
+ def _build_config_focus(rows: list[dict]) -> dict:
254
+ groups: dict[str, list[dict]] = {}
255
+ for row in rows:
256
+ tags = row.get("config_tags") or []
257
+ for tag in tags[:4]:
258
+ t = str(tag).strip()
259
+ if not t:
260
+ continue
261
+ groups.setdefault(t, []).append(row)
262
+ strong: list[dict] = []
263
+ weak: list[dict] = []
264
+ for tag, items in groups.items():
265
+ avg_ach = sum(_f(x.get("achievement")) for x in items if _f(x.get("achievement")) > 0) / len([x for x in items if _f(x.get("achievement")) > 0]) if any(_f(x.get("achievement")) > 0 for x in items) else 0.0
266
+ avg_gap = sum(_f(x.get("gap_vs_peer")) for x in items if x.get("gap_vs_peer") is not None) / len([x for x in items if x.get("gap_vs_peer") is not None]) if any(x.get("gap_vs_peer") is not None for x in items) else 0.0
267
+ entry = {"tag": tag, "count": len(items), "avg_achievement": round(avg_ach, 4), "avg_gap_vs_peer": round(avg_gap, 4)}
268
+ if len(items) >= 2 and avg_ach >= 100.3:
269
+ strong.append(entry)
270
+ elif len(items) >= 2 and avg_ach < 100.0:
271
+ weak.append(entry)
272
+ strong.sort(key=lambda x: (-x["avg_achievement"], -x["count"]))
273
+ weak.sort(key=lambda x: (x["avg_achievement"], -x["count"]))
274
+ return {"strong": strong[:5], "weak": weak[:5]}
275
+
276
+ def _load_assets_context(assets_path: str) -> dict:
277
+ if not assets_path:
278
+ return {}
279
+ assets = Path(assets_path)
280
+ kb = _load_json(assets, "kb", "mai_knowledge.json") or {}
281
+ roast = _load_json(assets, "kb", "roast_memory.json") or {}
282
+ chart_summary = _load_json(assets, "chart_summary.json") or {}
283
+ music_data = _load_json(assets, "music_data.json") or {}
284
+ return {
285
+ "kb": kb,
286
+ "roast_memory": roast,
287
+ "chart_summary": chart_summary,
288
+ "music_data": music_data,
289
+ }
290
+
291
+
292
+ def build_context(b50_data: dict, peer_stats: dict | None = None) -> dict:
293
+ player = {
294
+ "nickname": b50_data.get("nickname") or b50_data.get("username") or "maimai player",
295
+ "username": b50_data.get("username") or "",
296
+ "rating": _i(b50_data.get("rating")),
297
+ "qq": str(b50_data.get("qq") or ""),
298
+ }
299
+
300
+ sd = [_normalize(c) for c in ((b50_data.get("charts") or {}).get("sd") or [])[:35]]
301
+ dx = [_normalize(c) for c in ((b50_data.get("charts") or {}).get("dx") or [])[:15]]
302
+ all_charts = sd + dx
303
+
304
+ assets_ctx = _load_assets_context(str(b50_data.get("_assets_path") or ""))
305
+
306
+ if not all_charts:
307
+ return {"player": player, "peer_stats": {}, "summary": {}, "evidence": {}, "b50": [], **assets_ctx}
308
+
309
+ b35_ra = sum(_i(c.get("ra")) for c in sd)
310
+ b15_ra = sum(_i(c.get("ra")) for c in dx)
311
+ avg_ach = sum(c["achievement"] for c in all_charts) / len(all_charts)
312
+ avg_ds = sum(_f(c.get("ds")) for c in all_charts) / len(all_charts)
313
+ b35_avg = sum(c["achievement"] for c in sd) / len(sd) if sd else 0.0
314
+ b15_avg = sum(c["achievement"] for c in dx) / len(dx) if dx else 0.0
315
+
316
+ peer_data: dict = {}
317
+ if peer_stats:
318
+ rating = player["rating"]
319
+ sz = _i(peer_stats.get("rating_bucket_size"), 200)
320
+ lo = (rating // sz) * sz
321
+ bucket = (peer_stats.get("buckets") or {}).get(f"{lo}-{lo + sz - 1}") or {}
322
+ chart_stats = bucket.get("charts") or {}
323
+ if chart_stats:
324
+ gaps, overlaps = [], []
325
+ for c in all_charts:
326
+ key = f"{c['music_id']}:{_i(c.get('level_index'), -1)}"
327
+ stat = chart_stats.get(key)
328
+ if stat:
329
+ avg = _f(stat.get("avg_achievement"))
330
+ gap = c["achievement"] - avg
331
+ appear = _f(stat.get("b50_appear_rate"))
332
+ if appear <= 1:
333
+ appear *= 100
334
+ c["peer_avg"] = avg
335
+ c["gap"] = gap
336
+ c["overlap"] = appear
337
+ gaps.append(gap)
338
+ overlaps.append(appear)
339
+ if gaps:
340
+ peer_data = {
341
+ "available": True,
342
+ "bucket": f"{lo}-{lo + sz - 1}",
343
+ "matched": len(gaps),
344
+ "arpi": round(sum(gaps) / len(gaps), 4),
345
+ "b50_overlap": {"value": round(sum(overlaps) / len(overlaps), 2)},
346
+ }
347
+
348
+ with_gap = [c for c in all_charts if c.get("gap") is not None]
349
+ highlights = sorted(with_gap, key=lambda c: c.get("gap", 0), reverse=True)[:4]
350
+ ordinaries = sorted(with_gap, key=lambda c: c.get("gap", 0))[:2]
351
+ highest_ra = sorted(all_charts, key=lambda c: _i(c.get("ra")), reverse=True)[:1]
352
+ overlap_extremes: list[dict] = []
353
+ if with_gap:
354
+ hi = max(with_gap, key=lambda c: c.get("overlap", 0))
355
+ lo_c = min(with_gap, key=lambda c: c.get("overlap", 100))
356
+ overlap_extremes = [hi, lo_c] if hi is not lo_c else [hi]
357
+
358
+ summary = {
359
+ "b35_ra": b35_ra,
360
+ "b15_ra": b15_ra,
361
+ "avg_achievement": round(avg_ach, 4),
362
+ "avg_ds": round(avg_ds, 2),
363
+ "b35": {"avg_achievement": round(b35_avg, 4)},
364
+ "b15": {"avg_achievement": round(b15_avg, 4)},
365
+ }
366
+ if peer_data.get("arpi") is not None and with_gap:
367
+ summary["avg_peer"] = round(
368
+ sum(c.get("peer_avg", 0) for c in with_gap) / len(with_gap), 4
369
+ )
370
+ summary["avg_gap"] = round(
371
+ sum(c.get("gap", 0) for c in with_gap) / len(with_gap), 4
372
+ )
373
+
374
+ chart_summaries = assets_ctx.get("chart_summary") or {}
375
+ evidence_pack = _build_b50_evidence_pack(all_charts, player["rating"], peer_data, chart_summaries)
376
+ config_focus = evidence_pack.get("config_focus") or {}
377
+
378
+ return {
379
+ "player": player,
380
+ "peer_stats": peer_data,
381
+ "summary": summary,
382
+ "evidence": {
383
+ "highlights": highlights,
384
+ "ordinaries": ordinaries,
385
+ "highest_song_rating": highest_ra,
386
+ "overlap_extremes": overlap_extremes,
387
+ "same_rating_average_entry_points": evidence_pack.get("same_rating_average_entry_points", []),
388
+ "selected_evidence": evidence_pack.get("selected_evidence", []),
389
+ },
390
+ "b50_evidence_pack": evidence_pack,
391
+ "config_focus": config_focus,
392
+ "b50": all_charts,
393
+ "chart_summaries": chart_summaries,
394
+ **assets_ctx,
395
+ }
@@ -0,0 +1,15 @@
1
+ import httpx
2
+
3
+
4
+ async def fetch_b50(qq: str) -> dict:
5
+ async with httpx.AsyncClient(timeout=30) as client:
6
+ resp = await client.post(
7
+ "https://www.diving-fish.com/api/maimaidxprober/query/player",
8
+ json={"qq": int(qq), "b50": True},
9
+ )
10
+ if resp.status_code == 400:
11
+ raise ValueError(f"用户不存在或未开放 B50 查询(QQ: {qq})")
12
+ if resp.status_code == 403:
13
+ raise ValueError(f"该用户已关闭公开查询(QQ: {qq})")
14
+ resp.raise_for_status()
15
+ return resp.json()