上一篇我们搭建了转换器的“大脑”——给定一个标准化的英雄列表,它就能输出羁绊状态、阵容摘要和装备建议。但这还只是个半成品。真正好用的工具,应该能直接吞下乱七八糟的各种输入格式。这一篇,我们就来给大脑装上“眼睛和嘴巴”,并把它包装成一个即开即用的命令行工具。

一、回顾:内核已经就绪,就差输入层

在上一篇文章里,我们实现了两个核心函数:

  • calc_traits():接收英雄列表,返回激活的羁绊详情。

  • build_summary():接收英雄列表和羁绊结果,生成体检报告和问题清单。

只要把喂进去的数据整理成这种格式:

champions = [
    {"id": "TFT16_Aatrox", "star": 2, "items": [...], "position": {...}},
    ...
]

实际应用中,我们获取的数据往往五花八门:

  • Riot 官方 API 返回的复杂结构数据(participant.units 字段)
  • 截图识别模块生成的 JSON(字段命名不规范)
  • 玩家随意输入的文本(如"Aatrox, Riven, Garen"字符串)
  • 甚至直接拖入终端的图片文件(二进制数据流)

本文将重点解决数据格式兼容性问题,实现智能识别各类输入格式的功能,同时打造简洁高效的命令行交互界面。

二、多源输入的统一入口设计

转换器最终会暴露出一个主函数 convert(),它接收一个参数 source,内部自动判断 source 的类型并分发给对应的处理器。

def convert(source, assets_dir="./tft_assets", set_num=16):
    if isinstance(source, (bytes, bytearray)):
        return from_image(source, assets_dir)
    if isinstance(source, (dict, list)):
        return from_riot_json(source, set_num)
    if isinstance(source, str):
        s = source.strip()
        if s.startswith(("[", "{")):
            try:
                return from_riot_json(json.loads(s), set_num)
            except json.JSONDecodeError:
                pass
        return from_text(s, set_num)
    return {"error": f"不支持的输入类型: {type(source)}"}

这种基于类型判断和特征探测的双重机制,使得调用者无需关注数据来源,直接传入任意数据即可。接下来我们将详细解析各个格式处理器的工作原理。

2.1 Riot 官方 JSON 处理器:from_riot_json()

Riot 的 TFT 对局 API 返回的数据里,每个参与者身上都有一个 units 数组,每个 unit 包含 character_id(英雄API名)、tier(星级)、itemNames(装备名列表)、可选的位置信息等。

def from_riot_json(data: Any, set_num: int = 16) -> Dict:
    # 兼容两种结构:直接传 units 列表,或传包含 units 的 dict
    units = (data if isinstance(data, list)
             else data.get("units", data.get("champions", [])))
    champions: List[Dict] = []
    for u in units:
        raw_id = (u.get("character_id") or u.get("champion_id")
                  or u.get("id") or u.get("name") or "")
        champ_id = normalize_champ_id(raw_id, set_num)
        short_id = strip_prefix(champ_id)

        # 装备列表处理
        items = []
        for i in u.get("itemNames", u.get("items", [])):
            if i:
                items.append(normalize_item_id(i))

        champions.append({
            "id"      : champ_id,
            "short_id": short_id,
            "name_en" : short_id,
            "star"    : int(u.get("tier", 1)),   # Riot 用 tier 表示星级
            "cost"    : 0,                       # 稍后从数据库补全
            "items"   : items,
            "position": u.get("position", {}),
        })

    # 补全英雄费用(cost)
    _load_db()
    for c in champions:
        db_entry = _champion_db.get(c["id"], {})
        c["cost"] = db_entry.get("cost", 0)

    traits = calc_traits(champions, set_num)
    summary, issues = build_summary(champions, traits)
    return {
        "team_size": len(champions),
        "champions": champions,
        "traits": traits,
        "summary": summary,
        "equipment_issues": issues,
        "_source": "riot_json",
    }

值得关注的几个设计要点:

  1. 多字段容错机制:系统支持通过 character_id、champion_id、id 或 name 等多个可能字段进行查询,采用链式取值方式确保接口兼容性。

  2. 装备字段统一处理:针对不同 API 返回的 itemNames 和 items 等不一致字段名称,实现了统一兼容处理。

  3. 费用数据补充:由于 Riot 数据源不直接提供英雄费用信息,系统通过本地英雄数据库进行关联查询。为此需要确保 normalize_champ_id 的键名已正确映射。

2.2 自由文本处理器:from_text()

这是最“野生”的输入方式——玩家直接在聊天框敲了几个英雄名,希望快速看看阵容能开出什么羁绊。

def from_text(text: str, set_num: int = 16) -> Dict:
    _load_db()
    # 提取所有英文单词(支持单引号,比如 Cho'Gath)
    words = re.findall(r"[A-Za-z][A-Za-z']+", text)
    champions: List[Dict] = []
    found_ids: set = set()

    for word in words:
        for full_id, entry in _champion_db.items():
            sid = entry.get("short_id", "")
            # 大小写不敏感匹配
            if sid.lower() == word.lower() and full_id not in found_ids:
                champions.append({
                    "id"      : full_id,
                    "short_id": sid,
                    "name_en" : sid,
                    "star"    : 1,
                    "cost"    : entry.get("cost", 0),
                    "items"   : [],
                    "position": {},
                })
                found_ids.add(full_id)
                break

    if not champions:
        return {"error": "未能从文本中识别出英雄,请使用英雄英文 ID"}

    traits = calc_traits(champions, set_num)
    summary, issues = build_summary(champions, traits)
    return {
        "team_size": len(champions),
        "champions": champions,
        "traits": traits,
        "summary": summary,
        "equipment_issues": issues,
        "_source": "text",
    }

这段代码的核心逻辑是采用无脑匹配策略:

忽略标点、空格和大小写差异,直接用正则表达式提取单词文本。

然后遍历整个 _champion_db 数据库,逐个比对单词与英雄的 short_id(不区分大小写)。

一旦找到匹配项就立即终止内层循环,继续处理下一个单词。

存在的局限性(设计权衡): 当文本中出现如"Ashe"时,若数据库同时存在Ashe和AshenKing两个英雄,只会匹配到第一个符合条件的英雄(取决于字典遍历顺序)。不过在当前50+英雄的正常赛季中,这种歧义情况几乎不会出现,因此这个方案已经"足够好用"。

2.3 截图识别处理器:from_image()

截图识别本身是一个独立的大工程(下一篇博客会专门讲),这里我们假设已经有一个名为 tft_screen_capture 的 Python 模块,它提供了一个 recognize() 函数,输入图片字节流,返回分析结果。

def from_image(image_bytes: bytes, assets_dir: str = "./tft_assets") -> Dict:
    try:
        from tft_screen_capture import recognize
        result = recognize(image_bytes, assets_dir=assets_dir)
        if not result.get("error"):
            # 补全费用(因为识别模块可能不知道费用)
            _load_db()
            for c in result.get("champions", []):
                db_entry = _champion_db.get(c.get("id", ""), {})
                c["cost"] = db_entry.get("cost", 0)
        return result
    except ImportError:
        return {"error": "tft_screen_capture.py 未找到,请确保截图识别模块已安装"}
    except Exception as e:
        return {"error": str(e)}

处理逻辑很简单:尝试导入模块 → 调用识别 → 补全费用 → 返回结果。如果模块不存在或识别过程出错,统一返回一个带有 error 字段的字典,让上层知道发生了什么。

这里没有把截图识别逻辑写死在转换器里,而是保持模块解耦。转换器只负责“如果用户传了图片,我就委托给专门的模块去处理”。这种松耦合让两个模块可以独立迭代升级。

三、命令行入口:让脚本变成真正的工具

前面写的都是函数库,要想让普通用户(甚至是非程序员玩家)也能用,必须给它加一个命令行界面。在 Python 里,最简单的做法就是利用 if __name__ == "__main__": 块。

if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("用法: python tft_converter.py <input.json|text|image.png>")
        sys.exit(0)

    src_arg = sys.argv[1]
    src_path = Path(src_arg)

    # 判断是文件路径还是纯文本内容
    if src_path.exists():
        if src_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp"):
            data = src_path.read_bytes()
        else:
            data = src_path.read_text(encoding="utf-8")
            try:
                data = json.loads(data)
            except json.JSONDecodeError:
                pass
    else:
        data = src_arg

    result = convert(data)
    print(json.dumps(result, ensure_ascii=False, indent=2))

3.1 逐行解析

参数检查

if len(sys.argv) < 2:
    print("用法: python tft_converter.py <input.json|text|image.png>")
    sys.exit(0)

当用户不带任何参数运行脚本时,输出一行友好提示,告诉用户应该怎么用。这是命令行工具的基本素养。

智能判断输入介质

src_path = Path(src_arg)
if src_path.exists():
    # 是一个存在的文件路径
    if src_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp"):
        data = src_path.read_bytes()
    else:
        data = src_path.read_text(encoding="utf-8")
        try:
            data = json.loads(data)
        except json.JSONDecodeError:
            pass
else:
    data = src_arg
  • 根据文件是否存在及类型执行相应处理:

  • 对于存在的文件:

    • 图片文件(通过后缀名判断)以二进制模式读取
    • 其他文件以文本模式读取
      • 尝试用 json.loads 解析为 Python 对象
      • 若解析失败(如纯英雄名列表),保留原始字符串内容
  • 对于不存在的输入:

    • 直接将其视为原始数据(如 "Aatrox, Garen" 这类自由文本)

调用统一转换器并输出

result = convert(data)
print(json.dumps(result, ensure_ascii=False, indent=2))

不管数据怎么来、长什么样,最终都交给 convert() 处理,然后把结果以漂亮 JSON 的形式打印到控制台。

3.2 实际使用示例

# 1. 分析 Riot API 抓下来的对局数据
python tft_converter.py match_123456.json

# 2. 快速试算一个脑中构思的阵容
python tft_converter.py "Aatrox, Riven, Mordekaiser, Garen, Darius"

# 3. 分析手机截屏(需搭配 tft_screen_capture 模块)
python tft_converter.py my_team.png

# 4. 将结果保存到文件
python tft_converter.py my_team.json > analysis_result.json

四、兼容旧版函数名:不让重构变成破坏性更新

_calc_traits = calc_traits
_build_summary = build_summary

这是因为另一个还在开发中的项目 tft_screen_capture.py 里,内部调用的函数名还是老版本的 _calc_traits 和 _build_summary(带下划线前缀)。为了不立刻去修改那个仓库的代码,我在这里提供了向后兼容的别名。

五、本篇小结

我们成功将一个"仅支持标准格式"的阵容引擎,升级为能够直接处理图片、JSON甚至自然语句的实用工具。整个改造过程主要完成了三个关键改进:

  1. 统一接口设计

    • 创建convert()作为统一入口
    • 通过isinstance自动识别输入类型并路由到对应处理器
    • 完全屏蔽底层细节,调用者无需关心数据来源
  2. 多格式处理器实现

    • from_riot_json():解析官方API的复杂结构,采用多级字段回退机制确保兼容性
    • from_text():使用正则表达式提取英文单词,支持大小写不敏感的快速英雄匹配
    • from_image():与截图识别模块解耦,专注委托调用和费用补全
  3. 命令行集成

    • 利用sys.argvpathlib自动判断输入类型
    • 支持文件路径和纯文本两种输入方式
    • 保持脚本一键调用的便利性,同时不影响库功能

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐