Skill: zhangbin2025/weiqi-db (version 1.0.5)

--- name: 围棋棋谱数据库 description: weiqi-db 围棋棋谱数据库 - 本地棋谱管理工具，支持SGF导入、元数据编辑、标签管理、全文搜索。数据存储于单个JSON文件，AI友好的JSON接口设计。 tags: ["围棋", "weiqi", "go", "棋谱", "数据库", "SGF", "管理"] --- # 围棋棋谱数据库 (weiqi-db) > **🔒 安全说明**: 本技能为纯本地工具，所有数据存储在用户主目录下的 `~/.weiqi-db/database.json`，不涉及任何网络请求或外部服务。 本地围棋棋谱数据库，统一收纳来自野狐、本地SGF文件等来源的棋谱，支持全文搜索、标签管理、元数据编辑。 ## 核心特性 - **单文件存储**: 所有数据（含SGF内容）存储在一个JSON文件中 - **AI友好接口**: 默认JSON输出，便于程序解析 - **灵活查询**: JSON表达式查询语法，支持精确匹配和模糊搜索 - **元数据编辑**: 支持自动解析SGF + 手动覆盖补充 - **标签系统**: 多标签分类管理 - **SGF压缩**: 自动压缩存储，节省约75%磁盘空间 ## AI 执行规范（必读） 最短执行路径，最少 token 消耗： ### 1. 查询棋谱（使用简化参数） ```bash python3 ~/.openclaw/workspace/weiqi-db/scripts/db.py query --date YYYY-MM-DD --limit 10 python3 ~/.openclaw/workspace/weiqi-db/scripts/db.py query --player "柯洁" python3 ~/.openclaw/workspace/weiqi-db/scripts/db.py query --event-like "烂柯杯" ``` ### 2. 导出 SGF 文件（最高效方式） ```bash python3 ~/.openclaw/workspace/weiqi-db/scripts/db.py get --id "棋谱ID" -o /tmp/game.sgf ``` ### 3. 生成打谱网页（与 weiqi-sgf 配合） ```bash python3 ~/.openclaw/workspace/weiqi-db/scripts/db.py get --id "棋谱ID" -o /tmp/game.sgf python3 ~/.openclaw/workspace/weiqi-sgf/scripts/replay.py /tmp/game.sgf -o /tmp/ ``` ### 4. 禁止行为 ❌ - **禁止**直接读取 `~/.weiqi-db/database.json` 文件 - **禁止**使用 JSON 输出后再用 Python 解析提取 SGF - **禁止**不必要的 `cd` 命令，直接使用绝对路径 ## 安装依赖 ```bash pip3 install tinydb ``` ## CLI 接口 ### 初始化数据库 ```bash python3 db.py init ``` 创建 `~/.weiqi-db/database.json` 空数据库。 ### 添加棋谱 ```bash # 添加单个SGF文件 python3 db.py add --file game.sgf # 添加整个目录 python3 db.py add --dir <TEMP_DIR>/foxwq_downloads/2026-03-23/ --tag "野狐" # 添加时补充/覆盖元数据 python3 db.py add --file game.sgf --black "棋手A" --white "棋手B" --event "示例赛事" # 冲突处理策略 python3 db.py add --dir ./downloads/ --conflict skip # 默认：跳过重复 python3 db.py add --dir ./downloads/ --conflict overwrite # 覆盖已有棋谱 python3 db.py add --dir ./downloads/ --conflict keep # 保留两者 ``` **冲突检测类型：** - `hash`: SGF内容完全相同（基于MD5哈希） - `metadata`: 元数据重复（同棋手+同日期+同手数/结果，可能是同一局棋的不同来源） ### 查询棋谱 ```bash # 查询棋手（自动匹配黑棋或白棋） python3 db.py query --where '{"player": "示例棋手"}' # 查询赛事 python3 db.py query --where '{"event": "示例赛事"}' # 模糊搜索赛事 python3 db.py query --where '{"event~": "联赛"}' # 按标签查询 python3 db.py query --where '{"tags": "名局"}' # 日期范围 python3 db.py query --where '{"date>=": "2026-01-01"}' # 全字段模糊搜索 python3 db.py query --where '{"keyword": "中盘胜"}' # 组合条件 python3 db.py query --where '{"player": "示例棋手", "tags": "名局"}' # AND/OR 组合 python3 db.py query --where '{"$and": [{"player": "示例棋手"}, {"date": "2026-03-23"}]}' # 使用简化参数（无需 JSON） python3 db.py query --player "柯洁" python3 db.py query --date "2024-01-15" python3 db.py query --event "LG杯" python3 db.py query --event-like "杯赛" # 组合简化参数 python3 db.py query --player "柯洁" --date "2024-01-15" # 从文件读取查询条件（解决 exec 安全限制） echo '{"player": "柯洁"}' > /tmp/where.json python3 db.py query --where-file /tmp/where.json ``` ### 列出所有棋谱 ```bash python3 db.py list python3 db.py list --limit 10 ``` ### 获取单个棋谱（含SGF） ```bash python3 db.py get --id "2026032383118500" # 导出到文件（推荐，最高效） python3 db.py get --id "2026032383118500" -o /tmp/game.sgf ``` 返回完整的棋谱数据，包括 `sgf` 字段（SGF文件内容）。 ### 更新元数据 ```bash python3 db.py update --id "2026032383118500" --set '{"black": "修正名", "event": "测试赛事"}' # 从文件读取更新内容（解决 exec 安全限制） echo '{"black": "修正名", "event": "测试赛事"}' > /tmp/set.json python3 db.py update --id "2026032383118500" --set-file /tmp/set.json ``` ### 标签管理 ```bash # 添加单个标签 python3 db.py tag --id "xxx" --add "名局" # 移除单个标签 python3 db.py tag --id "xxx" --remove "测试" # 从文件批量添加标签（JSON 数组） echo '["名局", "经典", "AI讲解"]' > /tmp/tags.json python3 db.py tag --id "xxx" --add-file /tmp/tags.json # 从文件批量移除标签（JSON 数组） echo '["临时标签", "测试"]' > /tmp/remove_tags.json python3 db.py tag --id "xxx" --remove-file /tmp/remove_tags.json ``` ### 删除棋谱 ```bash python3 db.py delete --id "xxx" ``` ### 统计信息 ```bash python3 db.py stats ``` ## 查询语法（--where 参数） | 语法 | 含义 | 示例 | |------|------|------| | `{"player": "示例棋手"}` | 棋手名（搜 black 或 white） | 找该棋手的所有对局 | | `{"black": "黑方"}` | 只搜执黑 | 找黑方执黑的对局 | | `{"white": "白方"}` | 只搜执白 | 找白方执白的对局 | | `{"event": "示例赛事"}` | 赛事精确匹配 | 找特定比赛 | | `{"event~": "示例"}` | 赛事模糊匹配（~后缀） | 找含"示例"的比赛 | | `{"tags": "名局"}` | 包含标签 | 找标为"名局"的棋 | | `{"date": "2026-03-23"}` | 日期精确匹配 | 找特定日期 | | `{"date>=": "2026-01-01"}` | 日期大于等于 | 日期范围查询 | | `{"keyword": "中盘"}` | 全字段模糊搜索 | 任意字段含"中盘" | | `{"$and": [{}, {}]}` | AND 组合 | 同时满足多个条件 | | `{"$or": [{}, {}]}` | OR 组合 | 满足任一条件 | ## Exec 安全限制与 Workaround 在某些环境中（如受限的 exec 调用），直接传递 JSON 参数可能会遇到转义或安全限制问题。 ### 问题示例 ```bash # 复杂的 JSON 参数在 exec 中可能无法正常传递 python3 db.py query --where '{"$and": [{"player": "柯洁"}, {"date>=": "2024-01-01"}]}' python3 db.py update --id "xxx" --set '{"black": "新名字", "event": "新赛事", "tags": ["标签1", "标签2"]}' ``` ### 解决方案：使用文件传递参数 **1. 将 JSON 内容写入临时文件** ```bash # 查询条件 cat > /tmp/where.json << 'EOF' {"$and": [{"player": "柯洁"}, {"date>=": "2024-01-01"}]} EOF # 更新内容 cat > /tmp/set.json << 'EOF' {"black": "新名字", "event": "新赛事"} EOF ``` **2. 使用 --*-file 参数** ```bash # 查询 python3 db.py query --where-file /tmp/where.json # 更新 python3 db.py update --id "xxx" --set-file /tmp/set.json # 批量添加标签 echo '["名局", "经典", "AI讲解"]' > /tmp/tags.json python3 db.py tag --id "xxx" --add-file /tmp/tags.json ``` **3. 使用简化参数（无需 JSON）** ```bash # 对于简单查询，可以直接使用简化参数 python3 db.py query --player "柯洁" --date "2024-01-15" python3 db.py query --event-like "LG杯" ``` ### 参数互斥规则 - `--where` 和 `--where-file` 不能同时使用 - `--set` 和 `--set-file` 不能同时使用 - `--add` 和 `--add-file` 不能同时使用 - `--remove` 和 `--remove-file` 不能同时使用 ## 数据格式 数据库文件 `~/.weiqi-db/database.json` 结构： ```json { "version": 1, "games": [ { "id": "2026032383118500", "sgf": "(;GM[1]FF[4]...)", "black": "棋手A", "white": "棋手B", "black_rank": "九段", "white_rank": "九段", "date": "2026-03-23", "event": "示例围棋联赛", "result": "白中盘胜", "komi": "375", "movenum": 198, "tags": ["联赛", "AI讲解"], "hash": "a1b2c3d4...", "created": "2026-03-24T12:07:45" } ] } ``` ## AI 使用示例 **用户**: "找某棋手的棋" ```bash python3 db.py query --where '{"player": "示例棋手"}' ``` **用户**: "某杯赛决赛的名局" ```bash python3 db.py query --where '{"event~": "杯赛", "tags": "名局"}' ``` **用户**: "昨天下的棋" ```bash python3 db.py query --where '{"date": "2026-03-23"}' ``` **用户**: "获取某盘棋的SGF" ```bash python3 db.py get --id "xxx" -o /tmp/game.sgf ``` **用户**: "把刚才那盘棋标为名局" ```bash python3 db.py tag --id "xxx" --add "名局" ``` ## 技术说明 - **存储**: TinyDB (JSON-based，单文件) - **去重**: 基于 SGF 内容哈希值 - **搜索**: 内存索引 + 遍历过滤（适合 <10k 数据量） ## 相关技能 - [weiqi-foxwq](../weiqi-foxwq) - 野狐棋谱下载（棋谱来源） - [weiqi-yunbisai](../weiqi-yunbisai) - 云比赛网查询（比赛信息查询，不提供棋谱下载） ## 版本更新 ### v1.0.5 (2026-04-11) - ✅ 优化 AI 执行规范 - 使用绝对路径执行，无需 `cd` - 突出 `-o` 参数的文件导出功能 - 删除多余的步骤，最短执行路径 - 明确禁止直接读取 database.json 和 JSON 解析 ### v1.0.4 (2026-04-11) - ✅ 解决 exec 安全限制导致的 JSON 参数传递问题 - `query` 命令新增 `--where-file` 参数，支持从文件读取 JSON 查询条件 - `update` 命令新增 `--set-file` 参数，支持从文件读取 JSON 更新内容 - `tag` 命令新增 `--add-file` 和 `--remove-file` 参数，支持批量标签操作 - 新增简化查询参数：`--date`, `--player`, `--event`, `--event-like` - 新参数与原有参数互斥，保持向后兼容 ### v1.0.3 (2026-04-11) - ✅ 实现 SGF 自动压缩功能 - 使用 gzip + base64 压缩算法 - 压缩率约 75%（实测 9.6MB → 2.4MB） - 读取时自动解压，向后兼容旧数据 ### v1.0.2 (2026-04-11) - ✅ 新增 `get` 命令，支持通过 ID 获取完整棋谱（含 SGF） - ✅ 清理 `--json` 参数（默认始终 JSON 输出） ### v1.0.1 (2026-03-27) - ✅ 导入冲突检测功能 - 支持哈希级重复检测（相同SGF内容） - 支持元数据级重复检测（同棋手+同日期） - 三种冲突处理策略：`skip`(跳过)、`overwrite`(覆盖)、`keep`(保留两者) ### v1.0.0 (2026-03-24) - ✅ 初始版本发布 - ✅ 支持 SGF 导入、元数据编辑、标签管理 - ✅ JSON 查询语法，AI 友好接口

path: scripts/db.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
weiqi-db 围棋棋谱数据库
本地棋谱管理工具，AI友好的JSON接口设计

依赖: pip3 install tinydb
"""

import os
import sys
import re
import json
import hashlib
import argparse
import gzip
import base64
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional

# TinyDB
from tinydb import TinyDB, Query
from tinydb.operations import add, delete

# 导入 SGF 解析器
import importlib.util
spec = importlib.util.spec_from_file_location("sgf_parser", Path(__file__).parent / "sgf_parser.py")
sgf_parser = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sgf_parser)
_parse_sgf_full = sgf_parser.parse_sgf

# 数据库路径
DB_DIR = Path.home() / ".weiqi-db"
DB_PATH = DB_DIR / "database.json"


def ensure_db() -> TinyDB:
    """确保数据库存在并返回实例"""
    DB_DIR.mkdir(parents=True, exist_ok=True)
    return TinyDB(DB_PATH)


def parse_sgf(sgf_content: str) -> Dict[str, Any]:
    """从SGF内容解析元数据（复用 weiqi-sgf 的解析器）"""
    result = _parse_sgf_full(sgf_content)
    info = result.get('game_info', {})
    stats = result.get('stats', {})
    
    return {
        'black': info.get('black', '黑棋'),
        'white': info.get('white', '白棋'),
        'black_rank': info.get('black_rank', ''),
        'white_rank': info.get('white_rank', ''),
        'date': info.get('date', ''),
        'event': info.get('game_name', ''),
        'result': info.get('result', ''),
        'komi': info.get('komi', ''),
        'handicap': info.get('handicap', 0),
        'movenum': stats.get('move_nodes', 0),
    }


def calc_hash(sgf_content: str) -> str:
    """计算SGF内容哈希（去重用）"""
    # 移除空白字符后计算哈希
    normalized = re.sub(r'\s+', '', sgf_content.strip())
    return hashlib.md5(normalized.encode('utf-8')).hexdigest()[:16]


def compress_sgf(sgf_content: str) -> str:
    """压缩SGF内容（gzip + base64）"""
    compressed = gzip.compress(sgf_content.encode('utf-8'))
    return "__gz__" + base64.b64encode(compressed).decode('ascii')


def decompress_sgf(data: str) -> str:
    """解压SGF内容（自动检测是否压缩）"""
    if data.startswith("__gz__"):
        compressed = base64.b64decode(data[6:])
        return gzip.decompress(compressed).decode('utf-8')
    return data  # 未压缩的旧数据


def calc_diff(new_meta: Dict[str, Any], existing: Dict[str, Any]) -> Dict[str, Any]:
    """计算新旧记录的元数据差异"""
    diff = {}
    fields = ['black', 'white', 'black_rank', 'white_rank', 'date', 'event', 'result', 'komi', 'movenum']
    
    for field in fields:
        old_val = existing.get(field, '')
        new_val = new_meta.get(field, '')
        if old_val != new_val:
            diff[field] = {'old': old_val, 'new': new_val}
    
    return diff


_id_counter = 0

def generate_id() -> str:
    """生成唯一ID（时间戳+递增序号）"""
    global _id_counter
    _id_counter += 1
    timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
    return f"{timestamp}{_id_counter:04d}"


def format_output(data: Any) -> str:
    """格式化JSON输出"""
    return json.dumps(data, ensure_ascii=False, indent=2)


def cmd_init(args):
    """初始化数据库"""
    DB_DIR.mkdir(parents=True, exist_ok=True)
    db = TinyDB(DB_PATH)
    
    # 初始化空表
    if 'games' not in db.tables():
        db.table('games')
    
    return {
        "success": True,
        "path": str(DB_PATH),
        "message": "数据库初始化成功"
    }


def find_conflicts(table, meta: Dict[str, Any], content_hash: str) -> List[Dict]:
    """
    查找可能的冲突棋谱
    冲突类型：
    1. 哈希完全重复（相同SGF内容）
    2. 元数据重复（同棋手、同日期、可能同一局棋）
    """
    conflicts = []
    all_games = table.all()
    
    for game in all_games:
        conflict_type = None
        
        # 类型1: 哈希完全重复
        if game.get('hash') == content_hash:
            conflict_type = 'hash'
        # 类型2: 元数据重复（同棋手 + 同日期）
        elif (game.get('black') == meta.get('black') and 
              game.get('white') == meta.get('white') and
              game.get('date') == meta.get('date') and
              meta.get('date')):  # 确保日期不为空
            # 同一对棋手在同一天的比赛，可能是重复棋谱
            conflict_type = 'metadata'
        
        if conflict_type:
            conflicts.append({
                'id': game.get('id'),
                'type': conflict_type,
                'game': game
            })
    
    return conflicts


def cmd_add(args):
    """添加棋谱"""
    db = ensure_db()
    table = db.table('games')
    
    results = []
    added_count = 0
    skipped_count = 0
    overwritten_count = 0
    conflict_details = []
    
    # 冲突处理策略
    conflict_strategy = getattr(args, 'conflict', 'skip')  # skip, overwrite, keep
    
    # 收集要添加的文件
    files = []
    if args.file:
        files.append(Path(args.file))
    elif args.dir:
        dir_path = Path(args.dir)
        if dir_path.exists():
            files.extend(sorted(dir_path.glob('*.sgf')))
    
    if not files:
        return {"success": False, "error": "未找到SGF文件"}
    
    # 跟踪错误数量（文件不存在等）vs 跳过数量（冲突跳过）
    error_count = 0
    
    # 获取已有哈希（去重用）
    existing_hashes = {g.get('hash', '') for g in table.all()}
    
    for file_path in files:
        if not file_path.exists():
            results.append({"file": str(file_path), "success": False, "error": "文件不存在"})
            error_count += 1
            continue
        
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                sgf_content = f.read()
            
            # 计算哈希
            content_hash = calc_hash(sgf_content)
            
            # 解析SGF
            meta = parse_sgf(sgf_content)
            
            # 检查冲突
            conflicts = find_conflicts(table, meta, content_hash)
            
            if conflicts:
                conflict = conflicts[0]  # 取第一个冲突
                conflict_type = conflict['type']
                existing_id = conflict['id']
                
                if conflict_strategy == 'skip':
                    skipped_count += 1
                    conflict_details.append({
                        "file": str(file_path),
                        "action": "skipped",
                        "conflict_type": conflict_type,
                        "existing_id": existing_id
                    })
                    conflict_desc = '相同棋谱' if conflict_type == 'hash' else '可能重复'
                    results.append({
                        "file": str(file_path), 
                        "success": False, 
                        "error": f"冲突: {conflict_desc}",
                        "conflict_type": conflict_type,
                        "existing_id": existing_id
                    })
                    continue
                
                elif conflict_strategy == 'overwrite':
                    # 删除旧记录，添加新记录
                    Game = Query()
                    table.remove(Game.id == existing_id)
                    existing_hashes.discard(conflict['game'].get('hash', ''))
                    overwritten_count += 1
                    conflict_details.append({
                        "file": str(file_path),
                        "action": "overwritten",
                        "conflict_type": conflict_type,
                        "existing_id": existing_id
                    })
                
                elif conflict_strategy == 'keep':
                    # 保留两者，不做任何处理，直接添加
                    pass
            
            # 命令行参数覆盖
            if args.black:
                meta['black'] = args.black
            if args.white:
                meta['white'] = args.white
            if args.black_rank:
                meta['black_rank'] = args.black_rank
            if args.white_rank:
                meta['white_rank'] = args.white_rank
            if args.date:
                meta['date'] = args.date
            if args.event:
                meta['event'] = args.event
            if args.result:
                meta['result'] = args.result
            if args.komi:
                meta['komi'] = args.komi
            
            # 压缩SGF内容
            compressed_sgf = compress_sgf(sgf_content)
            
            # 构建记录
            game_id = generate_id()
            record = {
                "id": game_id,
                "sgf": compressed_sgf,
                "hash": content_hash,
                "black": meta['black'],
                "white": meta['white'],
                "black_rank": meta['black_rank'],
                "white_rank": meta['white_rank'],
                "date": meta['date'],
                "event": meta['event'],
                "result": meta['result'],
                "komi": meta['komi'],
                "handicap": meta.get('handicap', 0),
                "movenum": meta['movenum'],
                "tags": args.tag if args.tag else [],
                "created": datetime.now().isoformat()
            }
            
            table.insert(record)
            existing_hashes.add(content_hash)
            added_count += 1
            
            results.append({
                "file": str(file_path),
                "success": True,
                "id": game_id,
                "fields": {
                    "black": record['black'],
                    "white": record['white'],
                    "date": record['date'],
                    "event": record['event']
                }
            })
            
        except Exception as e:
            results.append({"file": str(file_path), "success": False, "error": str(e)})
    
    # 如果有错误（如文件不存在），返回失败
    if error_count > 0 and error_count == len(files):
        return {
            "success": False,
            "error": "所有文件处理失败",
            "added": added_count,
            "skipped": skipped_count,
            "overwritten": overwritten_count,
            "total": len(files),
            "conflict_strategy": conflict_strategy,
            "conflicts": conflict_details,
            "results": results
        }
    
    return {
        "success": True,
        "added": added_count,
        "skipped": skipped_count,
        "overwritten": overwritten_count,
        "total": len(files),
        "conflict_strategy": conflict_strategy,
        "conflicts": conflict_details,
        "results": results
    }


def match_condition(game: Dict, key: str, value: Any) -> bool:
    """单条件匹配"""
    # 特殊字段 player: 匹配 black 或 white
    if key == 'player':
        return value.lower() in game.get('black', '').lower() or \
               value.lower() in game.get('white', '').lower()
    
    # 模糊匹配 (~后缀)
    if key.endswith('~'):
        field = key[:-1]
        return value.lower() in str(game.get(field, '')).lower()
    
    # 大于等于 (>=后缀)
    if key.endswith('>='):
        field = key[:-2]
        return str(game.get(field, '')) >= str(value)
    
    # 小于等于 (<=后缀)
    if key.endswith('<='):
        field = key[:-2]
        return str(game.get(field, '')) <= str(value)
    
    # 大于 (>后缀)
    if key.endswith('>'):
        field = key[:-1]
        return str(game.get(field, '')) > str(value)
    
    # 小于 (<后缀)
    if key.endswith('<'):
        field = key[:-1]
        return str(game.get(field, '')) < str(value)
    
    # 标签特殊处理
    if key == 'tags':
        tags = game.get('tags', [])
        return value in tags
    
    # keyword: 全字段模糊搜索
    if key == 'keyword':
        keyword = value.lower()
        fields_to_search = ['black', 'white', 'event', 'result', 'date', 'sgf']
        for field in fields_to_search:
            if keyword in str(game.get(field, '')).lower():
                return True
        return False
    
    # 默认精确匹配
    return game.get(key) == value


def evaluate_where(game: Dict, where: Dict) -> bool:
    """递归评估 where 条件"""
    if not where:
        return True
    
    # $and: 所有条件都满足
    if '$and' in where:
        return all(evaluate_where(game, cond) for cond in where['$and'])
    
    # $or: 任一条件满足
    if '$or' in where:
        return any(evaluate_where(game, cond) for cond in where['$or'])
    
    # $not: 条件不满足
    if '$not' in where:
        return not evaluate_where(game, where['$not'])
    
    # 普通字段匹配
    for key, value in where.items():
        if not match_condition(game, key, value):
            return False
    
    return True


def cmd_query(args):
    """查询棋谱"""
    db = ensure_db()
    table = db.table('games')
    
    # 解析 where 条件
    where = {}
    
    # 检查 --where 和 --where-file 是否同时使用
    if args.where and args.where_file:
        return {"success": False, "error": "不能同时使用 --where 和 --where-file"}
    
    if args.where_file:
        # 从文件读取 where 条件
        try:
            where_file_path = Path(args.where_file)
            if not where_file_path.exists():
                return {"success": False, "error": f"where 文件不存在: {args.where_file}"}
            where_content = where_file_path.read_text(encoding='utf-8')
            where = json.loads(where_content)
        except json.JSONDecodeError as e:
            return {"success": False, "error": f"where 文件 JSON 解析错误: {e}"}
        except Exception as e:
            return {"success": False, "error": f"读取 where 文件失败: {e}"}
    elif args.where:
        try:
            where = json.loads(args.where)
        except json.JSONDecodeError as e:
            return {"success": False, "error": f"JSON解析错误: {e}"}
    
    # 处理简化查询参数
    if args.date:
        where['date'] = args.date
    if args.player:
        where['player'] = args.player
    if args.event:
        where['event'] = args.event
    if args.event_like:
        where['event~'] = args.event_like
    
    # 过滤
    games = [g for g in table.all() if evaluate_where(g, where)]
    
    # 排序
    if args.sort:
        reverse = args.sort.startswith('-')
        sort_key = args.sort.lstrip('-')
        games.sort(key=lambda x: x.get(sort_key, ''), reverse=reverse)
    else:
        # 默认按日期倒序
        games.sort(key=lambda x: x.get('date', ''), reverse=True)
    
    # 限制数量
    total = len(games)
    if args.limit:
        games = games[:args.limit]
    
    # 清理输出（移除SGF内容，减少体积）
    clean_games = []
    for g in games:
        cg = {k: v for k, v in g.items() if k != 'sgf' and k != 'hash'}
        clean_games.append(cg)
    
    return {
        "success": True,
        "count": len(clean_games),
        "total": total,
        "games": clean_games
    }


def cmd_list(args):
    """列出所有棋谱"""
    db = ensure_db()
    table = db.table('games')
    
    games = table.all()
    
    # 排序
    games.sort(key=lambda x: x.get('date', ''), reverse=True)
    
    total = len(games)
    if args.limit:
        games = games[:args.limit]
    
    # 清理输出
    clean_games = []
    for g in games:
        cg = {k: v for k, v in g.items() if k != 'sgf' and k != 'hash'}
        clean_games.append(cg)
    
    return {
        "success": True,
        "count": len(clean_games),
        "total": total,
        "games": clean_games
    }


def cmd_update(args):
    """更新元数据"""
    db = ensure_db()
    table = db.table('games')
    
    if not args.id:
        return {"success": False, "error": "需要指定 --id"}
    
    # 检查 --set 和 --set-file 是否同时使用
    if args.set and args.set_file:
        return {"success": False, "error": "不能同时使用 --set 和 --set-file"}
    
    updates = {}
    if args.set_file:
        # 从文件读取 set 内容
        try:
            set_file_path = Path(args.set_file)
            if not set_file_path.exists():
                return {"success": False, "error": f"set 文件不存在: {args.set_file}"}
            set_content = set_file_path.read_text(encoding='utf-8')
            updates = json.loads(set_content)
        except json.JSONDecodeError as e:
            return {"success": False, "error": f"set 文件 JSON 解析错误: {e}"}
        except Exception as e:
            return {"success": False, "error": f"读取 set 文件失败: {e}"}
    elif args.set:
        try:
            updates = json.loads(args.set)
        except json.JSONDecodeError as e:
            return {"success": False, "error": f"JSON解析错误: {e}"}
    else:
        return {"success": False, "error": "需要指定 --set 或 --set-file"}
    
    Game = Query()
    games = table.search(Game.id == args.id)
    
    if not games:
        return {"success": False, "error": f"未找到ID: {args.id}"}
    
    # 更新字段
    table.update(updates, Game.id == args.id)
    
    return {
        "success": True,
        "id": args.id,
        "updated": list(updates.keys())
    }


def cmd_tag(args):
    """标签管理"""
    db = ensure_db()
    table = db.table('games')
    
    if not args.id:
        return {"success": False, "error": "需要指定 --id"}
    
    # 检查 --add 和 --add-file 是否同时使用
    if args.add and args.add_file:
        return {"success": False, "error": "不能同时使用 --add 和 --add-file"}
    
    # 检查 --remove 和 --remove-file 是否同时使用
    if args.remove and args.remove_file:
        return {"success": False, "error": "不能同时使用 --remove 和 --remove-file"}
    
    Game = Query()
    games = table.search(Game.id == args.id)
    
    if not games:
        return {"success": False, "error": f"未找到ID: {args.id}"}
    
    # 处理 --add-file
    if args.add_file:
        try:
            add_file_path = Path(args.add_file)
            if not add_file_path.exists():
                return {"success": False, "error": f"add 文件不存在: {args.add_file}"}
            add_content = add_file_path.read_text(encoding='utf-8')
            tags_to_add = json.loads(add_content)
            if not isinstance(tags_to_add, list):
                return {"success": False, "error": "add 文件内容必须是 JSON 数组"}
        except json.JSONDecodeError as e:
            return {"success": False, "error": f"add 文件 JSON 解析错误: {e}"}
        except Exception as e:
            return {"success": False, "error": f"读取 add 文件失败: {e}"}
        
        def add_tags(doc):
            tags = doc.get('tags', [])
            for tag in tags_to_add:
                if tag not in tags:
                    tags.append(tag)
            return {'tags': tags}
        
        table.update(add_tags, Game.id == args.id)
        return {"success": True, "id": args.id, "action": "add_tags", "tags": tags_to_add}
    
    # 处理 --remove-file
    elif args.remove_file:
        try:
            remove_file_path = Path(args.remove_file)
            if not remove_file_path.exists():
                return {"success": False, "error": f"remove 文件不存在: {args.remove_file}"}
            remove_content = remove_file_path.read_text(encoding='utf-8')
            tags_to_remove = json.loads(remove_content)
            if not isinstance(tags_to_remove, list):
                return {"success": False, "error": "remove 文件内容必须是 JSON 数组"}
        except json.JSONDecodeError as e:
            return {"success": False, "error": f"remove 文件 JSON 解析错误: {e}"}
        except Exception as e:
            return {"success": False, "error": f"读取 remove 文件失败: {e}"}
        
        def remove_tags(doc):
            tags = doc.get('tags', [])
            for tag in tags_to_remove:
                if tag in tags:
                    tags.remove(tag)
            return {'tags': tags}
        
        table.update(remove_tags, Game.id == args.id)
        return {"success": True, "id": args.id, "action": "remove_tags", "tags": tags_to_remove}
    
    elif args.add:
        # 添加单个标签（避免重复）
        def add_tag(doc):
            tags = doc.get('tags', [])
            if args.add not in tags:
                tags.append(args.add)
            return {'tags': tags}
        
        table.update(add_tag, Game.id == args.id)
        return {"success": True, "id": args.id, "action": "add_tag", "tag": args.add}
    
    elif args.remove:
        # 移除单个标签
        def remove_tag(doc):
            tags = doc.get('tags', [])
            if args.remove in tags:
                tags.remove(args.remove)
            return {'tags': tags}
        
        table.update(remove_tag, Game.id == args.id)
        return {"success": True, "id": args.id, "action": "remove_tag", "tag": args.remove}
    
    else:
        # 显示当前标签
        game = games[0]
        return {"success": True, "id": args.id, "tags": game.get('tags', [])}


def cmd_get(args):
    """通过ID获取单个棋谱的完整内容"""
    db = ensure_db()
    table = db.table('games')

    if not args.id:
        return {"success": False, "error": "需要指定 --id"}

    Game = Query()
    games = table.search(Game.id == args.id)

    if not games:
        return {"success": False, "error": f"未找到ID: {args.id}"}

    # 解压SGF内容
    game = games[0].copy()
    sgf_content = decompress_sgf(game['sgf'])
    game['sgf'] = sgf_content

    # 如果指定了输出文件，将SGF写入文件
    if hasattr(args, 'output') and args.output:
        try:
            output_path = Path(args.output)
            output_path.write_text(sgf_content, encoding='utf-8')
            return {
                "success": True,
                "exported": True,
                "output_path": str(output_path.absolute()),
                "game_id": game['id'],
                "message": f"SGF内容已导出到: {output_path.absolute()}"
            }
        except Exception as e:
            return {
                "success": False,
                "error": f"文件写入失败: {str(e)}"
            }

    return {
        "success": True,
        "game": game
    }


def cmd_delete(args):
    """删除棋谱"""
    db = ensure_db()
    table = db.table('games')
    
    if not args.id:
        return {"success": False, "error": "需要指定 --id"}
    
    Game = Query()
    games = table.search(Game.id == args.id)
    
    if not games:
        return {"success": False, "error": f"未找到ID: {args.id}"}
    
    table.remove(Game.id == args.id)
    
    return {
        "success": True,
        "id": args.id,
        "deleted": True
    }


def cmd_clear(args):
    """清空数据库 - 直接重写文件确保彻底清空"""
    DB_DIR.mkdir(parents=True, exist_ok=True)
    
    # 直接重写 JSON 文件，绕过 TinyDB 的缓存机制
    empty_db = {"games": {}}
    with open(DB_PATH, 'w', encoding='utf-8') as f:
        json.dump(empty_db, f, ensure_ascii=False, indent=2)
    
    # 验证清空结果
    db = TinyDB(DB_PATH)
    table = db.table('games')
    count = len(table.all())
    
    return {
        "success": True,
        "message": "数据库已清空",
        "remaining_records": count
    }


def cmd_stats(args):
    """统计信息"""
    db = ensure_db()
    table = db.table('games')
    
    games = table.all()
    total = len(games)
    
    # 棋手统计
    players = {}
    for g in games:
        for color, field in [('black', 'black'), ('white', 'white')]:
            name = g.get(field, '未知')
            if name not in players:
                players[name] = {'total': 0, 'as_black': 0, 'as_white': 0}
            players[name]['total'] += 1
            if color == 'black':
                players[name]['as_black'] += 1
            else:
                players[name]['as_white'] += 1
    
    # 标签统计
    tags = {}
    for g in games:
        for tag in g.get('tags', []):
            tags[tag] = tags.get(tag, 0) + 1
    
    # 赛事统计
    events = {}
    for g in games:
        event = g.get('event', '未知')
        if event:
            events[event] = events.get(event, 0) + 1
    
    # 日期范围
    dates = [g.get('date', '') for g in games if g.get('date')]
    date_range = {
        'earliest': min(dates) if dates else None,
        'latest': max(dates) if dates else None
    }
    
    return {
        "success": True,
        "total_games": total,
        "date_range": date_range,
        "top_players": sorted(players.items(), key=lambda x: x[1]['total'], reverse=True)[:10],
        "top_tags": sorted(tags.items(), key=lambda x: x[1], reverse=True)[:10],
        "top_events": sorted(events.items(), key=lambda x: x[1], reverse=True)[:10]
    }


def main():
    parser = argparse.ArgumentParser(description='围棋棋谱数据库')
    subparsers = parser.add_subparsers(dest='command', help='命令')
    
    # init
    subparsers.add_parser('init', help='初始化数据库')
    
    # clear
    subparsers.add_parser('clear', help='清空所有棋谱')
    
    # add
    add_parser = subparsers.add_parser('add', help='添加棋谱')
    add_parser.add_argument('--file', help='单个SGF文件')
    add_parser.add_argument('--dir', help='SGF目录')
    add_parser.add_argument('--black', help='黑棋名')
    add_parser.add_argument('--white', help='白棋名')
    add_parser.add_argument('--black-rank', help='黑棋段位')
    add_parser.add_argument('--white-rank', help='白棋段位')
    add_parser.add_argument('--date', help='对局日期')
    add_parser.add_argument('--event', help='赛事名称')
    add_parser.add_argument('--result', help='对局结果')
    add_parser.add_argument('--komi', help='贴目')
    add_parser.add_argument('--tag', action='append', help='标签（可多次指定）')
    add_parser.add_argument('--conflict', choices=['skip', 'overwrite', 'keep'], 
                           default='skip', help='冲突处理策略: skip(跳过), overwrite(覆盖), keep(保留两者)')
    
    # query
    query_parser = subparsers.add_parser('query', help='查询棋谱')
    query_parser.add_argument('--where', help='查询条件（JSON）')
    query_parser.add_argument('--where-file', help='从文件读取查询条件（JSON）')
    query_parser.add_argument('--date', help='按日期查询（简化参数）')
    query_parser.add_argument('--player', help='按棋手查询（简化参数，匹配黑棋或白棋）')
    query_parser.add_argument('--event', help='按赛事查询（简化参数，精确匹配）')
    query_parser.add_argument('--event-like', help='按赛事模糊查询（简化参数）')
    query_parser.add_argument('--sort', help='排序字段（前缀-表示倒序）')
    query_parser.add_argument('--limit', type=int, help='限制数量')
    
    # list
    list_parser = subparsers.add_parser('list', help='列出所有棋谱')
    list_parser.add_argument('--limit', type=int, help='限制数量')
    
    # update
    update_parser = subparsers.add_parser('update', help='更新元数据')
    update_parser.add_argument('--id', required=True, help='棋谱ID')
    update_parser.add_argument('--set', help='更新内容（JSON）')
    update_parser.add_argument('--set-file', help='从文件读取更新内容（JSON）')
    
    # tag
    tag_parser = subparsers.add_parser('tag', help='标签管理')
    tag_parser.add_argument('--id', required=True, help='棋谱ID')
    tag_parser.add_argument('--add', help='添加单个标签')
    tag_parser.add_argument('--add-file', help='从文件读取标签列表（JSON数组）')
    tag_parser.add_argument('--remove', help='移除单个标签')
    tag_parser.add_argument('--remove-file', help='从文件读取要移除的标签列表（JSON数组）')
    
    # delete
    delete_parser = subparsers.add_parser('delete', help='删除棋谱')
    delete_parser.add_argument('--id', required=True, help='棋谱ID')

    # get
    get_parser = subparsers.add_parser('get', help='通过ID获取棋谱完整内容')
    get_parser.add_argument('--id', required=True, help='棋谱ID')
    get_parser.add_argument('--output', '-o', help='导出SGF到指定文件路径')

    # stats
    subparsers.add_parser('stats', help='统计信息')
    
    args = parser.parse_args()
    
    if not args.command:
        parser.print_help()
        sys.exit(1)
    
    # 执行命令
    commands = {
        'init': cmd_init,
        'clear': cmd_clear,
        'add': cmd_add,
        'query': cmd_query,
        'list': cmd_list,
        'update': cmd_update,
        'tag': cmd_tag,
        'delete': cmd_delete,
        'get': cmd_get,
        'stats': cmd_stats,
    }
    
    result = commands[args.command](args)
    
    # 输出结果（JSON格式）
    print(format_output(result))
    
    # 根据结果返回退出码
    sys.exit(0 if result.get('success', True) else 1)


if __name__ == '__main__':
    main()


path: scripts/sgf_parser.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SGF 树状解析模块 - 完整支持嵌套变化分支

对外接口仅2个:
- parse_sgf(sgf_content: str) -> dict
- parse_sgf_file(filepath: str) -> dict

树节点固定结构:
{
    "properties": dict,
    "is_root": bool,
    "move_number": int,
    "color": str|None,
    "coord": str|None,
    "children": list
}
"""

import re
import json
import unittest
from typing import Optional, List, Dict, Any


def parse_sgf(sgf_content: str) -> dict:
    """
    解析 SGF 内容
    
    返回结构:
    {
        "game_info": {
            "board_size": 19,
            "black": "黑棋",
            "white": "白棋",
            "black_rank": "9d",
            "white_rank": "9d",
            "game_name": "围棋棋谱",
            "date": "2024-01-01",
            "result": "B+R",
            "komi": "375",
            "handicap": 0,
            "handicap_stones": [{"x": 15, "y": 3}, ...]
        },
        "tree": {
            "properties": {"GM": "1", "FF": "4", ...},
            "is_root": true,
            "move_number": 0,
            "color": null,
            "coord": null,
            "children": [...]
        },
        "stats": {
            "total_nodes": 150,
            "move_nodes": 149,
            "max_depth": 80,
            "branch_count": 3
        },
        "errors": []
    }
    """
    parser = _SGFParser()
    return parser.parse(sgf_content)


def parse_sgf_file(filepath: str) -> dict:
    """解析 SGF 文件"""
    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
        content = f.read()
    return parse_sgf(content)


class _SGFParser:
    """内部解析器实现"""
    
    def __init__(self):
        self.errors: List[str] = []
        self._pending_branch_props: Dict[str, Any] = {}  # 缓存分支起始处的属性
    
    def parse(self, sgf_content: str) -> dict:
        """解析 SGF 内容"""
        self.errors = []
        
        content = sgf_content.strip()
        if not content:
            self.errors.append("SGF内容为空")
            return self._create_empty_result()
        
        try:
            root_node = self._parse_tree(content)
            tree = self._node_to_dict(root_node)
            stats = self._calc_stats(tree)
            game_info = self._extract_game_info(tree)
            
            return {
                "game_info": game_info,
                "tree": tree,
                "stats": stats,
                "errors": self.errors
            }
        except Exception as e:
            self.errors.append(f"解析错误: {str(e)}")
            return self._create_empty_result()
    
    def _create_empty_result(self) -> dict:
        """创建空结果"""
        empty_tree = {
            "properties": {},
            "is_root": True,
            "move_number": 0,
            "color": None,
            "coord": None,
            "children": []
        }
        return {
            "game_info": self._extract_game_info(empty_tree),
            "tree": empty_tree,
            "stats": {"total_nodes": 1, "move_nodes": 0, "max_depth": 0, "branch_count": 0},
            "errors": self.errors
        }
    
    def _parse_tree(self, content: str) -> '_SGFNode':
        """解析树状结构
        
        SGF格式: (;GM[1](;B[pd];W[pp])(;B[dd]))
        - 序列本身不创建节点，序列内的第一个 ';' 决定父节点
        - '(' 只标记进入新层级，')' 标记退出
        """
        # 父节点栈，存储序列的父节点（即序列内第一个节点的父节点）
        parent_stack: List['_SGFNode'] = []
        # 序列内当前处理的节点
        seq_current: Optional['_SGFNode'] = None
        root: Optional['_SGFNode'] = None
        i = 0
        n = len(content)
        paren_count = 0
        
        while i < n:
            char = content[i]
            
            if char == '(':
                # 开始新序列
                if seq_current is not None:
                    # 有序列内当前节点，作为新序列的父节点
                    parent_stack.append(seq_current)
                elif parent_stack:
                    # 没有序列内节点但有父栈，复制栈顶
                    parent_stack.append(parent_stack[-1])
                elif root is not None:
                    # 没有父栈但有根，根是新序列的父
                    parent_stack.append(root)
                # else: 第一个序列，parent_stack保持为空
                
                paren_count += 1
                seq_current = None
                i += 1
                
                # 预读并缓存 '(' 后的属性（如 C[...]），直到遇到 ';' 或 ')'
                self._pending_branch_props = {}
                while i < n:
                    c = content[i]
                    if c in '();':
                        break
                    if c in ' \t\n\r':
                        i += 1
                        continue
                    if c.isupper():
                        prop_name = ''
                        while i < n and content[i].isupper():
                            prop_name += content[i]
                            i += 1
                        values = []
                        while i < n and content[i] == '[':
                            value, i, closed = self._parse_property_value(content, i + 1)
                            if not closed:
                                self.errors.append(f"属性 {prop_name} 的值未闭合")
                            values.append(value)
                        if values:
                            self._pending_branch_props[prop_name] = values if len(values) > 1 else values[0]
                    else:
                        self.errors.append(f"位置 {i}: 分支注释中意外字符 '{c}'，跳过")
                        i += 1
            
            elif char == ')':
                # 结束当前序列
                if paren_count > 0:
                    if parent_stack:
                        parent = parent_stack.pop()
                        # 序列结束后，seq_current 应该是序列的父节点
                        # 以便下一个序列能正确地挂在同一父节点下
                        seq_current = parent
                    else:
                        seq_current = None
                    paren_count -= 1
                else:
                    self.errors.append(f"位置 {i}: 多余的右括号")
                i += 1
            
            elif char == ';':
                # 创建新节点
                new_node = _SGFNode()
                
                # 确定父节点
                if seq_current is not None and not seq_current.properties:
                    # seq_current 是刚由'('创建的空白节点，给它属性
                    # 这不应该发生，因为我们不再在'('时创建节点
                    parent = seq_current
                elif seq_current is not None:
                    # 序列内已有节点，新节点作为 seq_current 的子（主分支延续）
                    seq_current.children.append(new_node)
                    new_node.parent = seq_current
                    new_node.move_number = seq_current.move_number + 1 if not seq_current.is_root else 1
                elif parent_stack:
                    # 序列的第一个节点，父节点是 parent_stack 栈顶
                    parent = parent_stack[-1]
                    parent.children.append(new_node)
                    new_node.parent = parent
                    new_node.move_number = parent.move_number + 1 if not parent.is_root else 1
                elif root is None:
                    # 第一个节点，作为根
                    root = new_node
                    new_node.is_root = True
                    new_node.move_number = 0
                else:
                    # 另一个顶级节点（无括号包裹的情况）
                    if root.is_root and len(root.children) == 0 and not root.properties:
                        # 根是空的，直接使用
                        root.properties = {}
                        new_node.parent = root
                        new_node.move_number = 1
                        root.children.append(new_node)
                    elif root.is_root:
                        # 创建包裹节点
                        wrapper = _SGFNode()
                        wrapper.is_root = True
                        wrapper.move_number = 0
                        
                        if not root.properties and len(root.children) == 0:
                            # root 是空的，替换
                            root = wrapper
                        else:
                            # 移动原root
                            root.parent = wrapper
                            root.move_number = 1
                            wrapper.children.append(root)
                            root = wrapper
                        
                        new_node.parent = root
                        new_node.move_number = 1
                        root.children.append(new_node)
                    else:
                        new_node.parent = root
                        new_node.move_number = 1
                        root.children.append(new_node)
                
                # 解析属性
                props, i = self._parse_properties(content, i + 1)
                
                # 合并缓存的分支属性（如果有）
                if self._pending_branch_props:
                    # 缓存的属性优先，但已被解析的属性不会被覆盖
                    merged_props = self._pending_branch_props.copy()
                    merged_props.update(props)
                    props = merged_props
                    self._pending_branch_props = {}  # 清空缓存
                
                new_node.properties = props
                self._extract_move_info(new_node)
                
                seq_current = new_node
            
            elif char in ' \t\n\r':
                i += 1
            else:
                self.errors.append(f"位置 {i}: 意外字符 '{char}'，跳过")
                i += 1
        
        if paren_count > 0:
            self.errors.append("警告: 括号未完全闭合")
        

        
        return root or _SGFNode()
    
    def _parse_properties(self, content: str, start: int) -> tuple:
        """解析属性列表，返回 (属性字典, 新位置)"""
        props: Dict[str, Any] = {}
        i = start
        n = len(content)
        
        while i < n:
            char = content[i]
            
            if char in '();':
                break
            
            if char in ' \t\n\r':
                i += 1
                continue
            
            if char.isupper():
                prop_name = ''
                while i < n and content[i].isupper():
                    prop_name += content[i]
                    i += 1
                
                values = []
                while i < n and content[i] == '[':
                    value, i, closed = self._parse_property_value(content, i + 1)
                    if not closed:
                        self.errors.append(f"属性 {prop_name} 的值未闭合")
                    values.append(value)
                
                if values:
                    props[prop_name] = values if len(values) > 1 else values[0]
                else:
                    props[prop_name] = ''
            else:
                self.errors.append(f"位置 {i}: 属性名应为大写字母，跳过 '{char}'")
                i += 1
        
        return props, i
    
    def _parse_property_value(self, content: str, start: int) -> tuple:
        """解析属性值，正确处理转义
        返回: (值, 新位置, 是否正常闭合)
        """
        value = []
        i = start
        n = len(content)
        
        while i < n:
            char = content[i]
            
            if char == '\\' and i + 1 < n:
                next_char = content[i + 1]
                # SGF 转义规则：\] -> ], \\ -> \, \n -> 换行等
                if next_char == ']':
                    value.append(']')
                    i += 2
                elif next_char == '\\':
                    value.append('\\')
                    i += 2
                elif next_char == 'n':
                    value.append('\n')
                    i += 2
                elif next_char == 'r':
                    value.append('\r')
                    i += 2
                elif next_char == 't':
                    value.append('\t')
                    i += 2
                else:
                    # 其他字符直接保留
                    value.append(next_char)
                    i += 2
            elif char == ']':
                # 找到闭合括号
                i += 1
                return ''.join(value), i, True
            else:
                value.append(char)
                i += 1
        
        # 未正常闭合
        return ''.join(value), i, False
    
    def _extract_move_info(self, node: '_SGFNode'):
        """从属性中提取 color 和 coord"""
        if 'B' in node.properties:
            node.color = 'B'
            node.coord = self._normalize_coord(node.properties['B'])
        elif 'W' in node.properties:
            node.color = 'W'
            node.coord = self._normalize_coord(node.properties['W'])
    
    def _normalize_coord(self, val: Any) -> Optional[str]:
        """统一坐标格式"""
        if isinstance(val, list) and val:
            return val[0] if val[0] else None
        return val if val else None
    
    def _node_to_dict(self, node: '_SGFNode') -> dict:
        """将节点转换为字典"""
        return {
            "properties": node.properties,
            "is_root": node.is_root,
            "move_number": node.move_number,
            "color": node.color,
            "coord": node.coord,
            "children": [self._node_to_dict(c) for c in node.children]
        }
    
    def _calc_stats(self, tree: dict) -> dict:
        """计算统计信息"""
        total_nodes = 0
        move_nodes = 0
        max_depth = 0
        branch_count = 0
        
        def traverse(node: dict):
            nonlocal total_nodes, move_nodes, max_depth, branch_count
            total_nodes += 1
            if not node.get("is_root", False):
                move_nodes += 1
            max_depth = max(max_depth, node.get("move_number", 0))
            children = node.get("children", [])
            # 子节点数 > 1 表示有分支（除第一个主分支外的都是变化）
            if len(children) > 1:
                branch_count += len(children) - 1
            for child in children:
                traverse(child)
        
        traverse(tree)
        
        return {
            "total_nodes": total_nodes,
            "move_nodes": move_nodes,
            "max_depth": max_depth,
            "branch_count": branch_count
        }
    
    def _extract_game_info(self, tree: dict) -> dict:
        """从根节点提取棋局信息"""
        props = tree.get("properties", {})
        
        def get_prop(key: str, default: str = '') -> str:
            val = props.get(key, default)
            if isinstance(val, list) and val:
                return str(val[0])
            return str(val) if val else default
        
        # 棋盘大小
        try:
            board_size = int(get_prop('SZ', '19'))
        except ValueError:
            board_size = 19
        
        # 让子数
        try:
            handicap = int(get_prop('HA', '0'))
        except ValueError:
            handicap = 0
        
        # 让子位置
        handicap_stones = []
        ab_prop = props.get('AB', [])
        if isinstance(ab_prop, str):
            ab_prop = [ab_prop]
        if isinstance(ab_prop, list):
            for coord in ab_prop:
                if coord and len(str(coord)) >= 2:
                    c = str(coord)
                    x = ord(c[0]) - 97
                    y = ord(c[1]) - 97
                    if 0 <= x < board_size and 0 <= y < board_size:
                        handicap_stones.append({"x": x, "y": y})
        
        # 赛事名称：优先使用 EV (Event)， fallback 到 GN (Game Name)
        event_name = get_prop('EV')
        if not event_name:
            event_name = get_prop('GN', '围棋棋谱')
        
        return {
            "board_size": board_size,
            "black": get_prop('PB', '黑棋'),
            "white": get_prop('PW', '白棋'),
            "black_rank": get_prop('BR'),
            "white_rank": get_prop('WR'),
            "game_name": event_name,
            "date": get_prop('DT'),
            "result": get_prop('RE'),
            "komi": get_prop('KM', '375'),
            "handicap": handicap,
            "handicap_stones": handicap_stones
        }


class _SGFNode:
    """内部节点类"""
    
    def __init__(self):
        self.properties: Dict[str, Any] = {}
        self.is_root: bool = False
        self.move_number: int = 0
        self.color: Optional[str] = None
        self.coord: Optional[str] = None
        self.parent: Optional['_SGFNode'] = None
        self.children: List['_SGFNode'] = []


# ============ 单元测试 ============

class TestSGFParser(unittest.TestCase):
    """SGF 解析器单元测试"""
    
    def test_empty_sgf(self):
        """测试空 SGF"""
        result = parse_sgf("")
        self.assertIn("SGF内容为空", result['errors'])
        self.assertEqual(result['stats']['total_nodes'], 1)
        self.assertEqual(result['stats']['move_nodes'], 0)
    
    def test_root_only(self):
        """测试只有根节点"""
        sgf = "(;GM[1]FF[4]PB[黑棋]PW[白棋])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['game_info']['black'], '黑棋')
        self.assertEqual(result['game_info']['white'], '白棋')
        self.assertEqual(result['tree']['is_root'], True)
        self.assertEqual(result['tree']['move_number'], 0)
        self.assertEqual(result['tree']['color'], None)
        self.assertEqual(result['tree']['coord'], None)
        self.assertEqual(len(result['tree']['children']), 0)
        self.assertEqual(result['stats']['total_nodes'], 1)
        self.assertEqual(result['stats']['move_nodes'], 0)
        self.assertEqual(len(result['errors']), 0)
    
    def test_single_branch(self):
        """测试单分支（标准棋谱）"""
        sgf = "(;GM[1];B[pd];W[pp];B[dd])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['stats']['total_nodes'], 4)
        self.assertEqual(result['stats']['move_nodes'], 3)
        self.assertEqual(result['stats']['max_depth'], 3)
        self.assertEqual(result['stats']['branch_count'], 0)
        
        tree = result['tree']
        self.assertEqual(len(tree['children']), 1)
        
        first_move = tree['children'][0]
        self.assertEqual(first_move['is_root'], False)
        self.assertEqual(first_move['move_number'], 1)
        self.assertEqual(first_move['color'], 'B')
        self.assertEqual(first_move['coord'], 'pd')
        self.assertEqual(first_move['properties']['B'], 'pd')
        
        second_move = first_move['children'][0]
        self.assertEqual(second_move['move_number'], 2)
        self.assertEqual(second_move['color'], 'W')
        self.assertEqual(second_move['coord'], 'pp')
    
    def test_root_variations(self):
        """测试根节点多分支（无主分支）"""
        sgf = "(;GM[1](;B[pd])(;B[dd]))"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['stats']['total_nodes'], 3)
        self.assertEqual(result['stats']['move_nodes'], 2)
        self.assertEqual(result['stats']['branch_count'], 1)
        
        tree = result['tree']
        self.assertEqual(len(tree['children']), 2)
        
        # 第一个分支
        self.assertEqual(tree['children'][0]['color'], 'B')
        self.assertEqual(tree['children'][0]['coord'], 'pd')
        
        # 第二个分支（变化）
        self.assertEqual(tree['children'][1]['color'], 'B')
        self.assertEqual(tree['children'][1]['coord'], 'dd')
    
    def test_nested_variations(self):
        """测试嵌套变化分支"""
        sgf = "(;GM[1];B[pd](;W[pp])(;W[dp](;B[dd])(;B[qd])))"
        result = parse_sgf(sgf)
        
        # 树结构: Root -> B[pd] -> (W[pp], W[dp] -> (B[dd], B[qd]))
        # B[pd] 有 2 个子，贡献 1 个 branch
        # W[dp] 有 2 个子，贡献 1 个 branch
        # 总计 2 个 branch
        self.assertEqual(result['stats']['total_nodes'], 6)
        self.assertEqual(result['stats']['move_nodes'], 5)
        self.assertEqual(result['stats']['branch_count'], 2)
        
        tree = result['tree']
        # 根 -> B[pd]
        b_node = tree['children'][0]
        self.assertEqual(b_node['color'], 'B')
        
        # B[pd] 有两个子: W[pp] 和 W[dp]
        self.assertEqual(len(b_node['children']), 2)
        self.assertEqual(b_node['children'][0]['coord'], 'pp')
        self.assertEqual(b_node['children'][1]['coord'], 'dp')
        
        # W[dp] 有两个子: B[dd] 和 B[qd]
        w_dp_node = b_node['children'][1]
        self.assertEqual(len(w_dp_node['children']), 2)
        self.assertEqual(w_dp_node['children'][0]['coord'], 'dd')
        self.assertEqual(w_dp_node['children'][1]['coord'], 'qd')
    
    def test_escape_chars(self):
        """测试转义字符"""
        sgf = r"(;GM[1]C[Comment \] test])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['tree']['properties']['C'], "Comment ] test")
        self.assertEqual(len(result['errors']), 0)
    
    def test_handicap(self):
        """测试让子棋"""
        sgf = "(;GM[1]SZ[19]HA[2]AB[pd][dp];W[pp])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['game_info']['handicap'], 2)
        self.assertEqual(len(result['game_info']['handicap_stones']), 2)
        self.assertEqual(result['game_info']['handicap_stones'][0], {'x': 15, 'y': 3})
        self.assertEqual(result['game_info']['handicap_stones'][1], {'x': 3, 'y': 15})
        
        # 让子位置应在根节点
        ab = result['tree']['properties']['AB']
        self.assertIsInstance(ab, list)
        self.assertEqual(len(ab), 2)
    
    def test_multi_value_property(self):
        """测试多值属性"""
        sgf = "(;GM[1]AB[aa][bb][cc])"
        result = parse_sgf(sgf)
        
        ab = result['tree']['properties']['AB']
        self.assertIsInstance(ab, list)
        self.assertEqual(len(ab), 3)
        self.assertEqual(ab[0], 'aa')
        self.assertEqual(ab[1], 'bb')
        self.assertEqual(ab[2], 'cc')
    
    def test_invalid_sgf(self):
        """测试无效 SGF（未闭合属性值）"""
        sgf = "(;GM[1];B[pd;W[pp)"  # B的属性值未闭合
        result = parse_sgf(sgf)
        
        # 应该有错误（属性值未闭合）
        has_error = any("未闭合" in err or "属性" in err for err in result['errors'])
        self.assertTrue(has_error or len(result['errors']) > 0)
    
    def test_extra_close_paren(self):
        """测试多余右括号"""
        sgf = "(;GM[1];B[pd]))"
        result = parse_sgf(sgf)
        
        # 应该有多余的右括号错误或解析错误
        has_paren_error = any("括号" in err for err in result['errors'])
        self.assertTrue(has_paren_error or len(result['errors']) > 0)
    
    def test_pass_move(self):
        """测试虚手（停着）"""
        sgf = "(;GM[1];B[pd];W[];B[dd])"
        result = parse_sgf(sgf)
        
        tree = result['tree']
        w_node = tree['children'][0]['children'][0]
        self.assertEqual(w_node['color'], 'W')
        self.assertIsNone(w_node['coord'])
    
    def test_complex_tree(self):
        """测试复杂树结构"""
        sgf = """(;GM[1]FF[4]PB[黑棋]PW[白棋]
            (;B[pd];W[pp])
            (;B[dd];W[dp]
                (;B[pd])
                (;B[pp];W[pd](;B[qf])(;B[pf]))
            )
            (;B[dp];W[dd])
        )"""
        result = parse_sgf(sgf)
        
        # 根有 3 个子，贡献 2 个 branch
        # 第二个分支下又有分支，总共应有 4 个 branch
        self.assertEqual(result['stats']['branch_count'], 4)
        
        # 验证 game_info
        self.assertEqual(result['game_info']['black'], '黑棋')
        self.assertEqual(result['game_info']['white'], '白棋')
        self.assertEqual(result['game_info']['board_size'], 19)
    
    def test_multigo_format(self):
        """测试 MultiGo 格式的复杂棋谱（用户提供的棋谱）"""
        sgf = """(;CA[gb2312]AP[MultiGo:4.4.4]MULTIGOGM[0]

(;B[pd]N[b1];W[qc];B[qd];W[pc];B[oc];W[ob];B[nb];W[nc];B[od];W[mb];B[pb];W[na];B[qb])
(;B[pd]N[b2];W[qf]
(;B[qe]N[b21];W[pf];B[nd];W[pj])
(;B[nc]N[b22];W[rd];B[qc];W[qi]))
(;B[qd]N[b3];W[oc];B[pc];W[od];B[qf];W[kc]))"""
        
        result = parse_sgf(sgf)
        
        # 验证基本结构
        self.assertEqual(result['stats']['total_nodes'], 30)
        self.assertEqual(result['stats']['move_nodes'], 29)
        self.assertEqual(result['stats']['max_depth'], 13)
        self.assertEqual(result['stats']['branch_count'], 3)
        
        # 验证根节点属性
        self.assertEqual(result['tree']['properties']['CA'], 'gb2312')
        self.assertEqual(result['tree']['properties']['AP'], 'MultiGo:4.4.4')
        
        # 验证根有 3 个直接子节点（三个分支）
        self.assertEqual(len(result['tree']['children']), 3)
        
        # 验证 b1 分支
        b1 = result['tree']['children'][0]
        self.assertEqual(b1['properties']['N'], 'b1')
        self.assertEqual(b1['coord'], 'pd')
        self.assertEqual(b1['move_number'], 1)
        
        # 验证 b2 分支及其子分支
        b2 = result['tree']['children'][1]
        self.assertEqual(b2['properties']['N'], 'b2')
        # B[pd] N=b2 -> W[qf] -> (B[qe] N=b21, B[nc] N=b22)
        self.assertEqual(len(b2['children']), 1)  # W[qf]
        w_qf = b2['children'][0]
        self.assertEqual(len(w_qf['children']), 2)  # b21, b22 子分支
        
        # 验证 b3 分支
        b3 = result['tree']['children'][2]
        self.assertEqual(b3['properties']['N'], 'b3')
        self.assertEqual(b3['coord'], 'qd')


if __name__ == '__main__':
    print("=" * 60)
    print("SGF Parser 单元测试")
    print("=" * 60)
    
    # 创建测试套件
    loader = unittest.TestLoader()
    suite = loader.loadTestsFromTestCase(TestSGFParser)
    
    # 运行测试
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)
    
    print("=" * 60)
    if result.wasSuccessful():
        print("✓ 所有测试通过")
    else:
        print("✗ 测试失败")
        # 输出失败详情
        for failure in result.failures + result.errors:
            print(f"\n失败: {failure[0]}")
            print(failure[1])
    print("=" * 60)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)