nonebot-plugin-b50-analysis 0.1.0__tar.gz
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.
- nonebot_plugin_b50_analysis-0.1.0/PKG-INFO +96 -0
- nonebot_plugin_b50_analysis-0.1.0/README.md +71 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/__init__.py +82 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/config.py +17 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/context_builder.py +395 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/fetch.py +15 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/llm.py +272 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/paths.py +15 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/render.py +438 -0
- nonebot_plugin_b50_analysis-0.1.0/nonebot_plugin_b50_analysis/utils.py +30 -0
- nonebot_plugin_b50_analysis-0.1.0/pyproject.toml +27 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: nonebot-plugin-b50-analysis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 舞萌DX B50数据分析 NoneBot2 插件,支持锐评文本和分析图生成
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: nonebot2,maimai,maimaidx,b50,diving-fish
|
|
7
|
+
Author: HanYaaaaaaa
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: Pillow (>=10.0.0)
|
|
16
|
+
Requires-Dist: httpx (>=0.27.0)
|
|
17
|
+
Requires-Dist: nonebot-adapter-onebot (>=2.4.0)
|
|
18
|
+
Requires-Dist: nonebot-plugin-localstore (>=0.7.0)
|
|
19
|
+
Requires-Dist: nonebot2 (>=2.3.0)
|
|
20
|
+
Requires-Dist: openai (>=1.0.0)
|
|
21
|
+
Project-URL: Homepage, https://github.com/HanYaaaaaaa/nonebot-plugin-b50-analysis
|
|
22
|
+
Project-URL: Repository, https://github.com/HanYaaaaaaa/nonebot-plugin-b50-analysis
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# nonebot-plugin-b50-analysis
|
|
26
|
+
|
|
27
|
+
<div align="center">
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/nonebot-plugin-b50-analysis/)
|
|
30
|
+
[](https://nonebot.dev)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
舞萌DX B50 数据分析 NoneBot2 插件。从水鱼查分器拉取 B50,调用 LLM 生成锐评文本并渲染分析图。
|
|
36
|
+
|
|
37
|
+
关联项目:[HanYaaaaaaa/analyze-b50](https://github.com/HanYaaaaaaa/analyze-b50)(Claude Code Skill 版本)
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
nb install nonebot-plugin-b50-analysis
|
|
43
|
+
# 或
|
|
44
|
+
pip install nonebot-plugin-b50-analysis
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**必需: assets 资源包**:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 下载并解压到任意目录,然后在 .env 中配置 B50_ASSETS_PATH
|
|
51
|
+
curl -O http://t23.sjcmc.cn:34274/assets.zip
|
|
52
|
+
unzip assets.zip -d /path/to/assets
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 配置
|
|
56
|
+
|
|
57
|
+
在项目的 `.env` 或 `.env.prod` 中添加以下变量:
|
|
58
|
+
|
|
59
|
+
```dotenv
|
|
60
|
+
# 必填:OpenAI 兼容 API 的 base URL(末尾不带斜杠)
|
|
61
|
+
B50_LLM_URL=https://bitexingai.com/v1
|
|
62
|
+
|
|
63
|
+
# 必填:API Key
|
|
64
|
+
B50_LLM_KEY=sk-xxxxxxxxxxxxxxxx
|
|
65
|
+
|
|
66
|
+
# 可选:模型名称,默认 gemini-3-flash-preview
|
|
67
|
+
B50_LLM_MODEL=gemini-3-flash-preview
|
|
68
|
+
|
|
69
|
+
# 必填:assets 目录路径(含字体、图标、peer_stats.zip)
|
|
70
|
+
B50_ASSETS_PATH=/path/to/assets
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**推荐模型:gemini-3-flash-preview**
|
|
74
|
+
|
|
75
|
+
## 使用
|
|
76
|
+
|
|
77
|
+
| 指令 | 说明 |
|
|
78
|
+
|------|------|
|
|
79
|
+
| `分析b50` | 默认风格分析自己的 B50 |
|
|
80
|
+
| `分析b50 [风格/需求]` | 用指定风格或需求分析自己的 B50 |
|
|
81
|
+
|
|
82
|
+
## 示例输出
|
|
83
|
+
|
|
84
|
+

|
|
85
|
+
|
|
86
|
+
## 免责声明
|
|
87
|
+
|
|
88
|
+
- 本项目未与华立科技所运营的《舞萌DX》通讯服务器进行连接,不会对玩家账号数据造成任何影响。
|
|
89
|
+
- 本项目数据库中保护了用户隐私,样本数据来源于OneCatBot,游玩数据仅供模型训练,并未涉及用户隐私。
|
|
90
|
+
- 本项目llm提示词学习的口吻均为ai爬虫在互联网自行学习,非个人意向。
|
|
91
|
+
- 本项目大部分由 AI 辅助生成。
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
96
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# nonebot-plugin-b50-analysis
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/nonebot-plugin-b50-analysis/)
|
|
6
|
+
[](https://nonebot.dev)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
舞萌DX B50 数据分析 NoneBot2 插件。从水鱼查分器拉取 B50,调用 LLM 生成锐评文本并渲染分析图。
|
|
12
|
+
|
|
13
|
+
关联项目:[HanYaaaaaaa/analyze-b50](https://github.com/HanYaaaaaaa/analyze-b50)(Claude Code Skill 版本)
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
nb install nonebot-plugin-b50-analysis
|
|
19
|
+
# 或
|
|
20
|
+
pip install nonebot-plugin-b50-analysis
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**必需: assets 资源包**:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# 下载并解压到任意目录,然后在 .env 中配置 B50_ASSETS_PATH
|
|
27
|
+
curl -O http://t23.sjcmc.cn:34274/assets.zip
|
|
28
|
+
unzip assets.zip -d /path/to/assets
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 配置
|
|
32
|
+
|
|
33
|
+
在项目的 `.env` 或 `.env.prod` 中添加以下变量:
|
|
34
|
+
|
|
35
|
+
```dotenv
|
|
36
|
+
# 必填:OpenAI 兼容 API 的 base URL(末尾不带斜杠)
|
|
37
|
+
B50_LLM_URL=https://bitexingai.com/v1
|
|
38
|
+
|
|
39
|
+
# 必填:API Key
|
|
40
|
+
B50_LLM_KEY=sk-xxxxxxxxxxxxxxxx
|
|
41
|
+
|
|
42
|
+
# 可选:模型名称,默认 gemini-3-flash-preview
|
|
43
|
+
B50_LLM_MODEL=gemini-3-flash-preview
|
|
44
|
+
|
|
45
|
+
# 必填:assets 目录路径(含字体、图标、peer_stats.zip)
|
|
46
|
+
B50_ASSETS_PATH=/path/to/assets
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**推荐模型:gemini-3-flash-preview**
|
|
50
|
+
|
|
51
|
+
## 使用
|
|
52
|
+
|
|
53
|
+
| 指令 | 说明 |
|
|
54
|
+
|------|------|
|
|
55
|
+
| `分析b50` | 默认风格分析自己的 B50 |
|
|
56
|
+
| `分析b50 [风格/需求]` | 用指定风格或需求分析自己的 B50 |
|
|
57
|
+
|
|
58
|
+
## 示例输出
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
## 免责声明
|
|
63
|
+
|
|
64
|
+
- 本项目未与华立科技所运营的《舞萌DX》通讯服务器进行连接,不会对玩家账号数据造成任何影响。
|
|
65
|
+
- 本项目数据库中保护了用户隐私,样本数据来源于OneCatBot,游玩数据仅供模型训练,并未涉及用户隐私。
|
|
66
|
+
- 本项目llm提示词学习的口吻均为ai爬虫在互联网自行学习,非个人意向。
|
|
67
|
+
- 本项目大部分由 AI 辅助生成。
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -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()
|