联赛排名显示 · UI 说明文档
版本:v1.0 · 更新日期:2026-10-23
对应当前实现:standings.py+gui.py+scanner.py+radar_publish.py
一、功能概述
盘口规律雷达在比赛卡片、详情页、JSON 输出中,为主客队名追加该队在所属联赛的当前排名。显示格式对齐竞彩网的 [澳超9] 风格,帮助用户一眼识别两队实力层级差距。
修改前:布里斯班 vs 珀斯
修改后:布里斯班[澳超11] vs 珀斯[澳超8]
二、UI 显示位置
2.1 卡片列表(主界面)
文件:gui.py · 第 1168–1169 行
每场比赛卡片的队名标签由原来的纯队名改为带排名格式。使用 standings.rank_label() 统一生成。
┌─────────────────────────────────────┐
│ 周五001 澳超 2026-10-24 08:00 │
│ 布里斯班[澳超11] vs 珀斯[澳超8] │ ← 排名显示在队名后
│ 主胜 平 客胜 ✓ 倾向客胜 │
└─────────────────────────────────────┘
2.2 详情页标题
文件:gui.py · 第 1283–1284 行
点击卡片进入详情页后,标题行的主客队名同样带排名格式。
2.3 JSON 输出(radar_latest.json)
文件:radar_publish.py · _slim_match() 函数
JSON 里的队名字段从原来的 home / away(纯队名字符串)变为:
| 字段 | 类型 | 说明 |
|---|---|---|
home | str | 原始队名(纯文本,用于搜索/匹配) |
away | str | 原始队名 |
home_rank | int 或 null | 主队联赛排名 |
away_rank | int 或 null | 客队联赛排名 |
home_label | str | 带排名的完整显示文本(如 "布里斯班[澳超11]") |
away_label | str | 带排名的完整显示文本 |
使用建议:前端渲染时用 home_label / away_label,搜索/过滤时用 home / away。
三、显示格式规则
3.1 基本格式
队名[联赛简称+排名]
示例:
| 联赛 | 主客队 | 显示 |
|---|---|---|
| 澳超 | 布里斯班 vs 珀斯 | 布里斯班[澳超11] vs 珀斯[澳超8] |
| 英超 | 曼城 vs 阿森纳 | 曼城[英超1] vs 阿森纳[英超2] |
| 德甲 | 斯图加特 vs 门兴 | 斯图加特[德甲5] vs 门兴[德甲10] |
| 西甲 | 莱万特 vs 毕尔巴鄂 | 莱万特[西甲10] vs 毕尔巴鄂[西甲18] |
3.2 联赛名裁剪
rank_label() 会自动去除联赛名末尾的冗余后缀让显示更紧凑:
| 原始联赛名 | 裁剪后 |
|---|---|
| 西甲锦标赛 | 西甲 |
| 德国杯赛 | 德国杯 |
| 欧冠 | 欧冠(不变) |
3.3 降级规则
排名查询任一环节失败时,UI 自动降级为纯队名,不破坏旧行为:
| 失败场景 | 显示 |
|---|---|
| 联赛不在白名单(如欧冠、国际赛) | 布里斯班 vs 珀斯(跟以前一样) |
| 队名在数据库里查不到(normalizer 未知) | 沃夫斯堡 vs 波鸿 |
| 近 365 天该联赛已完赛场次不足 | A vs B |
jcw_history.db 不存在或损坏 | 全部显示纯队名 |
| normalizer 模块加载失败(主软件未安装) | 全部显示纯队名 |
四、覆盖范围
4.1 有排名的联赛
覆盖竞彩所有单一国家联赛。完整白名单见 standings.py 第 45–119 行 _LEAGUE_WHITELIST:
- 英超系:英超、英冠、英甲、英乙、英非联
- 西甲系:西甲、西乙、西协甲、西协乙
- 意甲系:意甲、意乙、意丙
- 德甲系:德甲、德乙、德丙
- 法甲系:法甲、法乙、法丙
- 荷甲系:荷甲、荷乙、荷丙
- 葡超系:葡超、葡甲
- 东欧:俄超、乌超、波甲、匈甲、捷甲、希超、塞超、克甲、丹超、斯亚甲
- 北欧:瑞超、挪超、芬超、苏超
- 西欧:比甲、奥超、土超
- 美洲:美职足、美超、墨超、墨甲、阿甲、巴甲、巴乙、智甲、哥伦甲、厄瓜多尔甲、秘鲁甲、委内瑞拉甲
- 亚洲:日职、日乙、日丙、韩职、韩乙、澳超、马来超、泰超、新加坡超、印尼甲、越南甲、香港超、印度超
- 非洲:埃及超、南非超
4.2 没有排名的联赛
白名单故意不包含以下赛制——因为它们不是单一国家联赛,"欧冠混合 79 支队一起排"这种排名没有意义:
| 不包含的联赛类型 | 示例 |
|---|---|
| 跨俱乐部杯赛 | 欧冠、欧罗巴、解放者杯、亚冠精英 |
| 国际赛 | 世预赛、国家友谊赛、美洲杯、亚洲杯 |
| 杯赛 | 国王杯、德国杯、巴西杯 |
| 竞彩特殊赛事 | 冠军杯、挑战杯 |
这些比赛在 UI 上的队名不带 [联赛名+排名] 后缀。
五、数据来源与刷新
5.1 数据源
| 项目 | 值 |
|---|---|
| 数据库文件 | D:\3D\SOFT\体彩足球竞彩\data\jcw_history.db |
| 数据表 | matches |
| 筛选条件 | league = 当前联赛 且 is_finished = 1 且 match_date >= 今日 - 365天 |
只用已完赛场次计算排名,进行中的比赛不影响积分榜。
5.2 计算规则
与 FIFA/欧洲主流联赛积分榜一致:
积分 = 胜 × 3 + 平 × 1 + 负 × 0
排序 = 积分 DESC → 净胜球 DESC → 进球数 DESC → 队名字母 ASC
5.3 365 天窗口设计
没有硬切赛季,而是滚动最近 12 个月的已完赛场次。原因:
- 跨赛季时战斗力排名更平滑(英超冬窗后不必等到 5 月才能反映)
- 竞彩比赛数据里的赛季边界不是很可靠
- 已完赛场次数量充足(每个顶级联赛每年 300–400 场)
5.4 缓存与刷新
首次调用 league_rank("曼城", "阿森纳", "英超")
→ _ensure("英超")
→ 查 _CACHE 有没有英超的 bucket
→ 没有 → 查 db_key=(db_path, mtime, size)
→ db 变化了(竞彩新更新了完赛结果) → 重建
→ db 没变 → 用内存缓存
→ 用 sqlite3 执行一次 SELECT WHERE league='英超' AND is_finished=1 AND match_date>=cutoff
→ 构建积分榜 → 存入缓存 → 返回
后续调用同一联赛 → 直接从缓存取,0 开销
| 触发重建条件 | 说明 |
|---|---|
jcw_history.db 的 mtime 或 size 变了 | 竞彩周末更新完赛结果后,下次扫描自动拿到新排名 |
显式调用 standings.clear_cache() | 测试/调试用 |
六、降级与容错设计
6.1 三级队名匹配
standings.py 的 _lookup() 函数会依次尝试,最大化命中率:
| 层级 | 方式 | 示例 |
|---|---|---|
| 1 | 原样命中索引 key | "曼城" → "曼城" |
| 2 | normalizer 扩展变体后逐个命中 | "Man City" → "曼城" |
| 3 | 双向 substring 模糊(唯一匹配时) | "布里斯班" ↔ "布里斯班狮吼" |
6.2 Normalizer 依赖
队名归一化依赖主软件的 graph_db.team_name_normalizer(懒加载,模块不存在时返回 None 降级为直接字符串比较)。normalizer 2026-10-23 批量补充了竞彩常用异译(沃夫斯堡→沃尔夫斯堡、墨胜利→墨尔本胜利、埃夫斯堡→埃尔夫斯堡、哈尔姆斯→哈尔姆斯达斯 等 20+ 条),以及架构级修复让 team_names_zh.json 里的 canonical 名自动成为 prefix-matching 的 key(布里斯班→布里斯班狮吼、珀斯→珀斯光荣、伊普斯→伊普斯维奇 等全部自动覆盖)。
⚠️ 共享依赖风险:team_name_normalizer.py 位于 D:\3D\SOFT\体彩足球竞彩\graph_db\ 下,是主软件「竞彩足球AI会话工具」的共享文件。本次修改(setdefault(zh, zh) 架构修复 + 20+ 条竞彩异译)会同时影响主软件的图数据库 UID 合并逻辑(compute_uid_merge_map),请确认主软件的合并诊断没有引入新的误合并。如果主软件使用 PyInstaller 打包,dist/ 目录下的副本需要重新打包才能同步生效——磁盘上的 dist/竞彩足球AI会话工具/_internal/graph_db/team_name_normalizer.py 不会自动跟随源码变化。
6.3 白名单守卫
任何不在 _LEAGUE_WHITELIST 的联赛直接返回空排名,不构建积分榜,彻底避免杯赛/国际赛的无意义混合排名。
6.4 try/except 全链路保护
# scanner.py 注入时的完整保护
try:
from standings import league_rank
feat["rank"] = league_rank(home, away, league)
except Exception:
feat["rank"] = {}
_ensure() 内部 _build() 也包了 try/except——即使 sqlite3 连不上、normalizer 崩溃、数据格式异常,都不会让整个扫描器挂掉。
七、技术实现
7.1 模块边界
standings.py ← 核心模块:积分榜计算 + 队名匹配 + 格式化
↑
│ 调用 (懒加载)
graph_db.team_name_normalizer ← 队名归一化(主软件共享依赖)
↑
│ 不在本目录下
│ 通过 config.get_main_app_path()
│ 从 D:\3D\SOFT\体彩足球竞彩\
│ 动态 sys.path 加载
scanner.py ← 注入点:扫描后 feat["rank"]
↓
radar_publish.py ← JSON 序列化:home_rank/away_rank/home_label/away_label
↓
gui.py ← UI 消费:卡片列表 + 详情页标题
7.2 核心接口(standings.py)
# 查询两队排名(主接口)
def league_rank(home: str, away: str, league: str) -> Dict
# 返回:{home_rank, away_rank, home_played, away_played, home_total, away_total, home_pts, away_pts, source}
# 格式化显示(供 GUI / JSON 共用)
def rank_label(team: str, rank: Optional[int], league: str = "") -> str
# 例子:rank_label("布里斯班", 11, "澳超") → "布里斯班[澳超11]"
# 例子:rank_label("布里斯班", None, "澳超") → "布里斯班" (降级)
# 测试用,强制重建缓存
def clear_cache() -> None
7.3 线程安全
_ensure() 内用 threading.Lock 保护 _CACHE 字典的读写。Python GIL + Lock 双重保障下,多线程并发调用不会产生竞态条件。
7.4 性能
2026-10-23 全扫描 35 场实测:
| 指标 | 数值 |
|---|---|
| 冷启动首次构建 12 个联赛 | ~3s(多联赛并行耗时) |
| 后续扫描 | ~4s(含全流程) |
| 每联赛首次 sql 查询 | < 0.1s |
| standings 缓存后命中率 | 100%(同一进程内) |
八、后续规划
P1 · archive.py 归档
让归档记录也保存 home_rank / away_rank,这样后续可以做"排名差距 vs 爆冷概率"的量化分析。当前 archive.py 里还没有排名字段。
P2 · Web 前端
radar_latest.json 已输出 home_label / away_label。如果 p3d.csh.space 的前端有自定义模板,需要把显示文本从 home / away 换成 home_label / away_label。
P3 · 白名单维护
竞彩新增联赛时(如 2026-27 赛季如果开新的亚冠精英联赛),需要手动往 _LEAGUE_WHITELIST 里加。可以在 scanner 的 feat["rank"] 注释区标一条提示方便后续定位。
P4 · Team name fallback 降级说明
如果主软件 team_name_normalizer.py 被卸载,standings 会自动降级为基于 substring 的模糊匹配(_lookup() 的第三步),但命中率会下降。建议在 normalizer 里持续补充竞彩常用简称。
附录:验证结果
2026-10-23 修复后(normalizer 批量补异译 + 白名单扩展):
| 指标 | 数值 |
|---|---|
| 联赛场次双方排名覆盖率 | 12/12 = 100% |
| scanner 扫描有 home_rank | 13/35 |
| 全扫描耗时 | 4.20s |
| normalizer 特别检查 | 11/11 ✓ |
之前失败的案例(现在全部命中):
| 输入 | 修复前 | 修复后 |
|---|---|---|
布里斯班 | 布里斯班 | 布里斯班狮吼 |
珀斯 | 珀斯 | 珀斯光荣 |
墨胜利 | 墨胜利 | 墨尔本胜利 |
沃夫斯堡 | 沃夫斯堡 | 沃尔夫斯堡 |
埃夫斯堡 | 埃夫斯堡 | 埃尔夫斯堡 |
哈尔姆斯 | 哈尔姆斯 | 哈尔姆斯达斯 |
米堡 | 米堡 | 米德尔斯堡 |
伊普斯 | 伊普斯 | 伊普斯维奇 |
毕尔巴鄂 | 毕尔巴鄂 | 毕尔巴鄂竞技 |
门兴 | 命中 | 命中(已工作) |