雀魂牌谱分析工具:从JSON数据解析到HTML战绩报告的全流程 简介一款面向雀魂玩家的牌谱分析工具支持国服、日服与国际服并提供 Windows、Linux、macOS 三平台运行包。工具参考天凤牌谱解析程序目前支持四人麻将牌谱分析覆盖被鸣牌与门清听牌两大类别以外的绝大多数数据项分析维度比较全面结果展示页面内置天凤凤凰桌的统计数据玩家可直接与自己的牌谱数据对比辅助复盘与水平提升。压缩包共 74 个文件以 JavaScript、HTML、CSS 构成前端展示与交互C 头文件与源文件实现核心分析算法JSON、YAML、Markdown 等负责配置、多语言和说明文档整体仅 1.18MB结构紧凑且便于按模块阅读。目前已有 6659 人学习使用。通过阅读源码可以了解牌谱获取与解析流程、多平台构建方式、算法模块划分以及结果页生成逻辑适合对麻将数据分析和开源项目感兴趣的中高级玩家或开发者。1. 雀魂牌谱分析工具从牌谱JSON到战绩报告的完整管线打雀魂打久了都会遇到同一个问题——打完一把好局除了截图什么都留不下。等想复盘的时候牌谱在游戏里翻半天找不到。这个MajsoulPaipuAnalyzer要解决的就是这个痛点把雀魂每一局牌谱的JSON数据拉下来解析成结构化的对局信息再生成一份可直接用浏览器打开的HTML分析报告。跑一次命令你就能看到这局每家的牌河、副露、立直时机、最终和牌/放铳归属还能按役种、番数、打点维度做独立统计。适合谁用玩雀魂想认真复盘的自学玩家、写麻将AI或分析脚本的开发者、以及研究牌谱数据结构的算法爱好者。2. 项目架构与技术选型一次牌谱分析的完整链路2.1 目录结构与职责划分拿到项目源码后我第一时间列了目录树整体结构很清晰没有把代码揉成一团。majsoul-paipu-analyzer/ ├── analyzer/ │ ├── __init__.py │ ├── parser.py # 牌谱JSON解析器 │ ├── models.py # 数据模型定义 │ ├── stats.py # 统计聚合逻辑 │ └── report.py # HTML报告生成 ├── data/ │ ├── paipu.json # 待分析的原始牌谱 │ └── yaku_map.json # 役种中文名映射表 ├── scripts/ │ └── run_analysis.py # 命令行入口 ├── templates/ │ └── report_template.html ├── requirements.txt └── README.md各层职责边界很分明。parser.py只负责读JSON、抽取字段、转换成内部数据对象不掺入任何统计逻辑stats.py专注在已经解析好的数据上做计算比如立直率、放铳率、平均打点这些指标report.py拿到统计结果套用模板渲染出HTML页面。分层带来的直接好处是你想换统计口径时不用动解析逻辑想换输出格式比如改成CSV也不用动统计模块。2.2 数据模型牌谱数据在内存里长什么样牌谱JSON原始结构是雀魂服务端导出的字段命名偏紧凑比如data数组里每一项代表一个操作类型是数字或缩写字符串。直接对着原始JSON写业务逻辑代码会因为魔法数字泛滥而没法维护。models.py里定义了几个核心模型类把数据做了归整。# analyzer/models.py from dataclasses import dataclass, field dataclass class Tile: suit: str # m万 p筒 s索 z字牌 rank: int # 1-9字牌为1-7对应东南西北白发中 property def display(self) - str: if self.suit z: return f{self.rank}Z return f{self.rank}{self.suit} classmethod def from_code(cls, code: int) - Tile: suit_idx (code - 1) // 9 rank (code - 1) % 9 1 suit [m, p, s, z][suit_idx] return cls(suitsuit, rankrank) dataclass class PlayerAction: seat: int # 0-3对应东南西北家 action_type: str # draw discard call win riichi tile: Tile None targets: list field(default_factorylist) dataclass class GameResult: players: list actions: list final_scores: list yaku_record: dict field(default_factorydict)Tile.from_code这个类方法值得单独说。雀魂牌谱里一张牌用一个整数表示1到9是万子1-9万10到18是饼子1-9饼19到27是索子28到34是字牌。这个编码规则没有写在任何文档里是我对比了多份牌谱数据才确认的。from_code把整数还原成(suit, rank)结构后续所有牌型判断都基于这个结构进行。PlayerAction里的seat字段也存在一个隐含约定0不是随机分配而是一开始东家的位置。这个字段在统计“庄家立直率”这类维度时很关键但解析时需要结合牌谱里额外的位置映射数据不能只凭局部操作记录推断。2.3 一次牌谱分析的执行流程整个工具对外只暴露一个入口处理链路分成四步走每一步都有明确产出。读取data/paipu.json检查文件是否存在、JSON格式是否合法调用parser.py逐字段解析将原始操作序列转换成GameResult对象把GameResult交给stats.py按玩家分组做指标聚合生成统计字典将统计字典和templates/report_template.html拼装输出output/report.html。第2步和第3步之间用一个中间对象解耦设计上有点类似ETL的LoadTransform思路。实际跑下来解析耗时占整体70%以上因为每条操作记录都要做字段映射和类型转换统计阶段反而是纯内存计算毫秒级完成。3. 核心实现拆解牌谱解析与役种判定3.1 牌谱JSON的读取与标准化牌谱文件本身是个典型的数据密集JSON一眼望去全是嵌套数组。parser.py的入口函数负责把原始数据标准化第一步就把乱糟糟的原始结构转成容易读的列表。# analyzer/parser.py import json from .models import PlayerAction, GameResult, Tile def load_paipu(filepath: str) - dict: with open(filepath, r, encodingutf-8) as fp: raw json.load(fp) # 牌谱JSON通常有header和data两个顶层字段 if data not in raw: raise ValueError(牌谱格式异常: 缺少data字段) return raw def parse_actions(raw_data: list) - list: actions [] for item in raw_data: # item 的典型结构: {type: 1, data: {...}} if item.get(type) 1: # 摸牌 actions.append(PlayerAction( seatitem[seat], action_typedraw, tileTile.from_code(item[tile]) )) elif item.get(type) 2: # 打牌 actions.append(PlayerAction( seatitem[seat], action_typediscard, tileTile.from_code(item[tile]) )) elif item.get(type) 3: # 副露 actions.append(PlayerAction( seatitem[seat], action_typecall, targets[Tile.from_code(t) for t in item[tiles]] )) elif item.get(type) 4: # 立直 actions.append(PlayerAction( seatitem[seat], action_typeriichi )) elif item.get(type) 5: # 和牌 actions.append(PlayerAction( seatitem[seat], action_typewin, tileTile.from_code(item[tile]) )) return actionstype字段的数字含义同样是从多份牌谱里比对归纳出来的1代表摸牌、2代表打牌、3代表副露、4代表立直、5代表和牌。之所以要加这个映射层是因为后续如果要支持“鸣牌时是否从牌山中摸打”这类精细维度避免在业务代码里到处散落魔法数字。解析时还有一个容易忽略的点seat字段不总是直接给出部分牌谱版本里玩家的座位映射藏在header的某个子对象里。我一般会单独写一个_extract_seat_map(raw)函数把座位码和玩家ID对应关系先解析出来再交给parse_actions使用。3.2 牌河分析与副露解析牌河是整个牌谱里信息量最集中的区域每一家打过的牌按顺序排列反映了进攻还是防守的判断路径。stats.py里构建牌河的代码是我认为本项目最见功夫的一段。# analyzer/stats.py from collections import defaultdict def build_river(actions, seat: int) - list: 返回某一家按时间序排列的牌河副露时标记call river [] for act in actions: if act.seat ! seat: continue if act.action_type discard: river.append({tile: act.tile, mode: tsumogiri}) elif act.action_type call: # 副露后打出的牌标注来源 river.append({tile: act.tile, mode: call_discard}) return river def count_riichi_stats(actions, seat: int) - dict: riichi_pos None for idx, act in enumerate(actions): if act.seat seat and act.action_type riichi: riichi_pos idx break stats {is_riichi: riichi_pos is not None} if riichi_pos is not None: stats[riichi_turn] len([a for a in actions[:riichi_pos] if a.seat seat]) return stats牌河里的“模切”摸到牌直接打出去和“手切”从手牌里挑一张打在牌谱里实际上是区分开的部分牌谱会在打牌操作里附带一个from_tsumogiri布尔字段。我这个版本的build_river用一个mode字段区分默认值取tsumogiri如果有这个字段就覆盖。展开成[tile: ..., mode: ...]而不是单纯的牌列表是为了后续生成报告时能标注颜色。副露解析要单独注意一点副露操作的targets列表里是副露后露出的牌组合比如碰的三张、吃的三张并不包含所有手牌。统计“副露后还剩几张手牌”这种需求需要在parser层就维护一个手牌计数器逐条操作加减这个状态留在parser内部会比放在stats里干净得多。3.3 役种判定与番数计算役种判定是所有麻将分析工具里最容易被吐槽“黑匣子”的部分——判断错了让人没法信任。这个项目把役种判定收敛在一个纯函数里输入是一组手牌含副露和牌谱记录的和牌条件输出是一个役种列表。# analyzer/stats.py def judge_yaku(tiles: list, win_tile: Tile, is_tsumo: bool, is_riichi: bool) - list: yaku [] # 门清自摸必须有役判断是否是门前清 is_menzen len(tiles) 13 # 简化判断仅看在没副露前提下 if is_riichi: yaku.append(立直) if is_tsumo: yaku.append(门清自摸) elif is_menzen and is_tsumo: yaku.append(门清自摸) # 常见标准形断幺九 has_terminal any(t.rank in (1, 9) or t.suit z for t in tiles) if not has_terminal and len(tiles) % 3 2: yaku.append(断幺九) # 对对和 tile_strs [f{t.suit}{t.rank} for t in tiles] counter defaultdict(int) for s in tile_strs: counter[s] 1 groups list(counter.values()) if len([c for c in groups if c 3]) 4: yaku.append(对对和) return yaku这段代码的逻辑很直白先处理立直下必然存在的役种再按组合学规则检查是否有常见役。真实的雀魂逻辑当然不止这些还有七对子、国士无双、一气通贯等每个都是一个独立条件分支代码会拉到二百行左右。坏消息是役种判定依赖完整14张手牌结构特别是非门清状态下手牌数不是13is_menzen的判断需要结合副露数 0来算这一行不能省。Tile对象设计成dataclass而不是简单字符串在这个函数里体现出了价值t.suit和t.rank可以用属性访问代码一眼能看出意图不用反复做字符串切片。3.4 HTML报告的生成解析和统计都做完之后最后一步是把数据渲染成视觉化页面。report.py用的是最传统但最不会出错的模板替换不引任何前端框架打开就是完整页面。# analyzer/report.py from string import Template def generate_report(stats: dict, template_path: str, output_path: str) - str: with open(template_path, r, encodingutf-8) as fp: html fp.read() template Template(html) # 生成牌河的HTML片段 rivers_html for seat_name, river in stats[rivers].items(): rivers_html fdiv classriver-blockh3玩家{seat_name}/h3 for entry in river: tile_cls call if entry[mode] call_discard else normal rivers_html fspan classtile {tile_cls}{entry[tile].display}/span rivers_html /div rendered template.safe_substitute( total_gamesstats[total_games], riichi_ratestats[riichi_rate], rivers_htmlrivers_html ) with open(output_path, w, encodingutf-8) as fp: fp.write(rendered) return output_pathstring.Template配合safe_substitute做替换比f-string拼整段HTML靠谱得多因为模板里如果有JS代码包含${}会直接炸。遇到模板里需要循环的场景就先用Python生成片段再插进去这也是一线开发常用的“手动渲染”方案。报告粒度上我建议按“单对局”和“批量对局”两种模式输出。单对局报告重点展示牌河和终局成绩批量模式跑一堆牌谱后聚合出立直率、平均打点、放铳率这类维度。现有实现里stats.py已具备批量计算的基础但report.py目前只处理了单局case。4. 运行配置与常见问题排查4.1 环境配置与依赖安装这个项目只依赖Python标准库不额外装第三方包这是我喜欢它的一个原因。requirements.txt里甚至只有一行注释——纯靠Python 3.9自带功能就能跑完整个流程。# 安装依赖其实什么都没装 pip install -r requirements.txt # 跑单局分析 python scripts/run_analysis.py --paipu data/paipu.json --output output/report.html命令行参数解析用的是argparse支持--paipu指定牌谱文件路径和--output指定报告输出位置。实际运行时如果output/目录不存在脚本会先自动创建这个细节避免了一类常见的“No such file or directory”错误。4.2 命令行参数与输出结果一个实际跑通的例子是这样python scripts/run_analysis.py --paipu data/paipu.json终端会打印解析过程中的进度信息包括“已解析”的牌谱行数、识别到的副露次数、和牌役种列表。如果不指定--output默认写到output/report.html用浏览器打开就能看到扇形统计图、牌河时间轴、各家终局分数和役种说明。参数表参数默认值说明--paipu无牌谱JSON路径必需参数--outputoutput/report.html报告保存路径--batchfalse批量模式传入目录路径4.3 避坑指南5个野路子排查案例第一牌谱里data字段缺失。现象是脚本一跑就KeyError: data报错指向load_paipu。原因是部分牌谱导出工具会生成精简版文件只保留摘要没有操作流程。解决方式是解析前先校验顶层字段缺了就给出明确错误提示而不是一路崩到堆栈深处。第二Tile.from_code对特殊牌红宝牌解析错误。红宝牌的编码和普通牌不同牌谱里会用单独字段标记只用code推(suit, rank)会丢掉红宝牌信息。解决方式是先查有无红宝牌标识字段有则单独标记不做编码计算。第三座位号对应关系错乱。东家在牌谱里是seat0但查询玩家分数时服务端给的不是座位号而是玩家ID如果只按座位号聚合就会张冠李戴。解决方式是先把玩家ID到座位号的映射单独抽出来统计时统一用映射后的座位号。第四JSON文件编码问题。Windows下直接保存的牌谱偶尔是GBK编码open()时用encodingutf-8固定读会报UnicodeDecodeError。解决方式是在读取时尝试两种编码或者要求用户统一转成UTF-8我倾向后者因为牌谱文件本身就应该以UTF-8存储。第五副露后的手牌数判断误判。早期版本用len(tiles)判断是否门前清副露三次后手牌剩4张len(tiles) % 3 2会误判不是断幺九。这个真翻过车后来改成在parser阶段就维护一个手牌计数器副露操作减掉露出的牌数统计阶段拿实时状态去判断。5. 二次开发与改造思路给分析工具加自定义“巡目”维度真正想把这工具用顺手不只是跑一遍报告而是要加自己的分析维度。我最推荐加的一个指标是“巡目”——也就是每一巡大家都打了什么对应到进攻时机分析里有很强解读价值。具体改造分三步走。第一步在models.py里给PlayerAction加一个turn字段在parse_actions里根据data中的巡目索引赋值。第二步在stats.py新增一个函数接收actions列表输出一个字典键是(seat, turn)值是该巡的操作类型。# analyzer/stats.py def build_turn_map(actions: list) - dict: turn_map {} for act in actions: # turn 字段在解析时已经写入 turn_map[(act.seat, act.turn)] act.action_type return turn_map第三步在report.py里把turn_map嵌到牌河区块中用横向时间轴展示每一巡的操作。经过这步改造工具从“看牌河结果”升级成“看牌河形成过程”分析价值完全不一样。验证改动手法拿同一份牌谱改造前后生成的报告对比一下。如果turn_map统计出的巡目数和牌谱里实际操作条数一致逻辑基本没跑偏。接着可以跑两局已知结果的牌谱看识别出的立直巡目是否符合对局常识这一步能堵住大多数数据错位问题。从那以后我每次改动数据模型都强制走一遍“旧牌谱跑通—对比新老输出—手算核对统计量”的流程。这工具看起来只是个小脚本实际上把你从“凭记忆复盘”变成了“拿数据说话”祝用得顺手希望帮到你。本文还有配套的精品资源点击获取