#!/usr/bin/env python3 """ XML 标签提取与同步脚本 (v4.0) > **v5.0 说明**: 此脚本用于**手动标注场景**(可选)。 > v5.0 主流程使用 Data Agent 从纯正文进行 AI 语义提取,不再依赖 XML 标签。 > 如果章节中包含 XML 标签,此脚本仍可用于解析和同步。 功能: 1. 扫描指定章节正文,提取所有 XML 格式标签 2. 支持标签类型: - : 实体(角色/地点/物品/势力/招式) - : 实体别名注册 - : 实体属性更新(支持 set/unset/add/remove/inc) - : 金手指技能 - : 伏笔标签 - : 大纲偏离标记 - : 角色关系 3. 支持实体层级分类(核心/支线/装饰) 4. 同步到设定集对应文件 5. 更新 state.json(entities_v3 + alias_index 一对多) 6. 支持自动化模式和交互式模式 v4.0 变更: - alias_index 改为一对多(同一别名可映射多个实体) - 删除旧格式兼容代码 - 新增操作:/// - 顶层字段白名单支持 使用方式: python extract_entities.py <章节文件> [--auto] [--dry-run] python extract_entities.py --project-root "path" --chapter 1 --auto """ import re import json import os import shutil import sys import argparse from pathlib import Path from datetime import datetime from typing import List, Dict, Tuple, Optional, Any # ============================================================================ # 安全修复:导入安全工具函数(P0 CRITICAL) # ============================================================================ from security_utils import sanitize_filename, create_secure_directory, atomic_write_json from project_locator import resolve_project_root, resolve_state_file from chapter_paths import find_chapter_file, extract_chapter_num_from_filename # Windows 编码兼容性修复 if sys.platform == 'win32': import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') # 实体类型与目标文件映射 ENTITY_TYPE_MAP = { "角色": "设定集/角色库/{category}/{name}.md", "地点": "设定集/世界观.md", "物品": "设定集/物品库/{name}.md", "势力": "设定集/世界观.md", "招式": "设定集/力量体系.md", "其他": "设定集/其他设定/{name}.md" } # 有效实体类型(v4.0 不再兼容旧别名) VALID_ENTITY_TYPES = {"角色", "地点", "物品", "势力", "招式"} # 顶层字段白名单(可通过 entity-update 直接修改) TOP_LEVEL_FIELDS = {"tier", "desc", "canonical_name", "importance", "status", "parent"} class AmbiguousAliasError(RuntimeError): """别名命中多个实体且无法消歧(必须改用 id 或补充 type)。""" def normalize_entity_type(raw: Any) -> str: """验证实体类型(v4.0 不再支持别名转换)。""" t = str(raw or "").strip() if not t: return "" if t in VALID_ENTITY_TYPES: return t return "" # 无效类型返回空 # 角色分类规则 ROLE_CATEGORY_MAP = { "主角": "主要角色", "配角": "次要角色", "反派": "反派角色", "路人": "次要角色" } # 实体层级权重(匹配伏笔三层级系统) ENTITY_TIER_MAP = { "核心": {"weight": 3.0, "desc": "必须追踪,影响主线"}, "core": {"weight": 3.0, "desc": "必须追踪,影响主线"}, "支线": {"weight": 2.0, "desc": "应该追踪,丰富剧情"}, "sub": {"weight": 2.0, "desc": "应该追踪,丰富剧情"}, "装饰": {"weight": 1.0, "desc": "可选追踪,增加真实感"}, "decor": {"weight": 1.0, "desc": "可选追踪,增加真实感"} } # ============================================================================ # 实体管理核心函数 (v3.0 新增) # ============================================================================ def generate_entity_id(entity_type: str, name: str, existing_ids: set) -> str: """ 生成唯一实体 ID 规则: 1. 优先使用拼音(去空格、小写) 2. 冲突时追加数字后缀 3. 特殊前缀按类型 Args: entity_type: 实体类型(角色/地点/物品/势力/招式) name: 实体名称 existing_ids: 已存在的 ID 集合 Returns: str: 唯一的实体 ID """ # 类型前缀映射 prefix_map = { "物品": "item_", "势力": "faction_", "招式": "skill_", "地点": "loc_" # 角色无前缀 } # 尝试使用 pypinyin,如果不可用则用简单的 hash try: from pypinyin import lazy_pinyin pinyin = ''.join(lazy_pinyin(name)) base_id = prefix_map.get(entity_type, '') + pinyin.lower() except ImportError: # pypinyin 不可用时,使用简化方案 import hashlib hash_suffix = hashlib.md5(name.encode('utf-8')).hexdigest()[:8] base_id = prefix_map.get(entity_type, '') + hash_suffix # 清理非法字符 base_id = re.sub(r'[^a-z0-9_]', '', base_id) # 处理冲突 final_id = base_id counter = 1 while final_id in existing_ids: final_id = f"{base_id}_{counter}" counter += 1 return final_id def resolve_entity_by_alias(alias: str, entity_type: Optional[str], state: dict) -> Tuple[Optional[str], Optional[str], Optional[dict]]: """ 通过别名解析实体(v4.0 一对多版本) Args: alias: 别名或名称 entity_type: 实体类型提示(可选,用于歧义消解) state: state.json 内容 Returns: (entity_type, entity_id, entity_data) 或 (None, None, None) Raises: AmbiguousAliasError: 别名命中多个实体且无法消歧(必须改用 id 或补充 type) ValueError: alias_index 数据格式不符合 v4.0 规范 """ alias_index = state.get("alias_index", {}) # alias_index 新格式: {"别名": [{"type": "角色", "id": "xxx"}, ...]} entries = alias_index.get(alias) if not entries: return (None, None, None) if not isinstance(entries, list): raise ValueError( f"alias_index 数据格式错误:期望 alias_index[{alias!r}] 为 list[{{type,id,...}}],实际为 {type(entries).__name__}" ) # 只有一个匹配 -> 直接返回 if len(entries) == 1: ref = entries[0] et = ref.get("type", "") eid = ref.get("id", "") entities_v3 = state.get("entities_v3", {}) entity_data = entities_v3.get(et, {}).get(eid) return (et, eid, entity_data) if entity_data else (None, None, None) # 多个匹配 -> 尝试用 type 消解 if entity_type: matches = [e for e in entries if e.get("type") == entity_type] if len(matches) == 1: ref = matches[0] et = ref.get("type", "") eid = ref.get("id", "") entities_v3 = state.get("entities_v3", {}) entity_data = entities_v3.get(et, {}).get(eid) return (et, eid, entity_data) if entity_data else (None, None, None) # 歧义无法消解:必须强制报错,避免写错实体 raise AmbiguousAliasError(f"别名歧义: {alias!r} 命中 {len(entries)} 个实体,请改用 id 或补充 type 属性") def ensure_entities_v3_structure(state: dict) -> dict: """ 确保 state.json 有 entities_v3 和 alias_index 结构 entities_v3 格式: { "角色": { "lintian": { "id": "lintian", "canonical_name": "林天", "aliases": ["废物", "林天"], "tier": "核心", "current": {...}, "history": [...], "created_chapter": 1 } }, "地点": {...}, ... } alias_index 格式 (v4.0 一对多): { "废物": [{"type": "角色", "id": "lintian"}], "天云宗": [ {"type": "地点", "id": "loc_tianyunzong"}, {"type": "势力", "id": "faction_tianyunzong"} ], ... } """ if "entities_v3" not in state: state["entities_v3"] = { "角色": {}, "地点": {}, "物品": {}, "势力": {}, "招式": {} } if "alias_index" not in state: state["alias_index"] = {} return state _XML_ATTR_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(["\'])(.*?)\2', re.DOTALL) def parse_xml_attributes(tag: str) -> Dict[str, str]: """从形如 `` 的片段中提取属性字典(不做 XML 语义校验)。""" attrs: Dict[str, str] = {} for m in _XML_ATTR_RE.finditer(tag): key = m.group(1).strip() value = m.group(3).strip() if not key: continue attrs[key] = value return attrs def _line_number_from_index(text: str, index: int) -> int: return text[:index].count("\n") + 1 def extract_new_entities(file_path: str) -> List[Dict]: """ 从章节文件中提取所有实体标签(v4.0 仅支持 XML 格式)。 支持 XML 形态: 1) 自闭合: 2) 成对: 废物 林宗主 Returns: List[Dict]: [{"type","name","desc","tier","id?","attrs","aliases","line","source_file"}, ...] """ p = Path(file_path) text = p.read_text(encoding="utf-8") entities: List[Dict[str, Any]] = [] # ============================================================ # XML 成对格式: ... (用于内嵌 alias) # ============================================================ block_pattern = re.compile(r"(?s)(]*>)(.*?)") for m in block_pattern.finditer(text): open_tag = m.group(1) body = m.group(2) attrs = parse_xml_attributes(open_tag) entity_type = str(attrs.get("type", "")).strip() entity_name = str(attrs.get("name", "")).strip() if not entity_type or not entity_name: continue # 验证 entity_type if entity_type not in VALID_ENTITY_TYPES: print(f"⚠️ 无效实体类型: {entity_type}(第{_line_number_from_index(text, m.start())}行),跳过") continue entity_desc = str(attrs.get("desc", "")).strip() entity_tier = str(attrs.get("tier", "支线")).strip() or "支线" if entity_tier.lower() not in ENTITY_TIER_MAP: entity_tier = "支线" entity_id = str(attrs.get("id", "")).strip() or None extra_attrs = {k: v for k, v in attrs.items() if k not in {"type", "id", "name", "desc", "tier"}} aliases = [a.strip() for a in re.findall(r"(?s)(.*?)", body) if str(a).strip()] entities.append( { "type": entity_type, "id": entity_id, "name": entity_name, "desc": entity_desc, "tier": entity_tier, "attrs": extra_attrs, "aliases": aliases, "line": _line_number_from_index(text, m.start()), "source_file": file_path, } ) # ============================================================ # XML 自闭合格式: # ============================================================ self_closing_pattern = re.compile(r"]*?/\s*>") for m in self_closing_pattern.finditer(text): tag = m.group(0) attrs = parse_xml_attributes(tag) entity_type = str(attrs.get("type", "")).strip() entity_name = str(attrs.get("name", "")).strip() if not entity_type or not entity_name: continue # 验证 entity_type if entity_type not in VALID_ENTITY_TYPES: print(f"⚠️ 无效实体类型: {entity_type}(第{_line_number_from_index(text, m.start())}行),跳过") continue entity_desc = str(attrs.get("desc", "")).strip() entity_tier = str(attrs.get("tier", "支线")).strip() or "支线" if entity_tier.lower() not in ENTITY_TIER_MAP: entity_tier = "支线" entity_id = str(attrs.get("id", "")).strip() or None extra_attrs = {k: v for k, v in attrs.items() if k not in {"type", "id", "name", "desc", "tier"}} entities.append( { "type": entity_type, "id": entity_id, "name": entity_name, "desc": entity_desc, "tier": entity_tier, "attrs": extra_attrs, "aliases": [], "line": _line_number_from_index(text, m.start()), "source_file": file_path, } ) return entities def extract_entity_alias_ops(file_path: str) -> List[Dict[str, Any]]: """ 提取实体别名操作: 可选:type="角色|地点|物品|势力|招式" 用于 disambiguation。 """ p = Path(file_path) text = p.read_text(encoding="utf-8") results: List[Dict[str, Any]] = [] pattern = re.compile(r"]*?/\s*>", re.IGNORECASE) for m in pattern.finditer(text): tag = m.group(0) attrs = parse_xml_attributes(tag) alias = str(attrs.get("alias", "")).strip() if not alias: continue results.append( { "id": str(attrs.get("id", "")).strip() or None, "ref": str(attrs.get("ref", "")).strip() or None, "type": str(attrs.get("type", "")).strip() or None, "alias": alias, "context": str(attrs.get("context", "")).strip(), "line": _line_number_from_index(text, m.start()), "source_file": file_path, } ) return results def extract_entity_update_ops(file_path: str) -> List[Dict[str, Any]]: """ 提取实体更新操作(v4.0 支持 set/unset/add/remove/inc): 可选:type="角色|地点|物品|势力|招式" 用于 disambiguation。 """ p = Path(file_path) text = p.read_text(encoding="utf-8") results: List[Dict[str, Any]] = [] block_pattern = re.compile(r"(?s)(]*>)(.*?)", re.IGNORECASE) for m in block_pattern.finditer(text): open_tag = m.group(1) body = m.group(2) attrs = parse_xml_attributes(open_tag) operations: List[Dict[str, Any]] = [] # for sm in re.finditer(r"]*?/\s*>", body, re.IGNORECASE): set_attrs = parse_xml_attributes(sm.group(0)) key = str(set_attrs.get("key", "")).strip() value = str(set_attrs.get("value", "")).strip() if not key: continue operations.append({ "op": "set", "key": key, "value": value, "reason": str(set_attrs.get("reason", "")).strip() }) # for sm in re.finditer(r"]*?/\s*>", body, re.IGNORECASE): set_attrs = parse_xml_attributes(sm.group(0)) key = str(set_attrs.get("key", "")).strip() if not key: continue operations.append({ "op": "unset", "key": key, "reason": str(set_attrs.get("reason", "")).strip() }) # for sm in re.finditer(r"]*?/\s*>", body, re.IGNORECASE): set_attrs = parse_xml_attributes(sm.group(0)) key = str(set_attrs.get("key", "")).strip() value = str(set_attrs.get("value", "")).strip() if not key or not value: continue operations.append({ "op": "add", "key": key, "value": value, "reason": str(set_attrs.get("reason", "")).strip() }) # for sm in re.finditer(r"]*?/\s*>", body, re.IGNORECASE): set_attrs = parse_xml_attributes(sm.group(0)) key = str(set_attrs.get("key", "")).strip() value = str(set_attrs.get("value", "")).strip() if not key or not value: continue operations.append({ "op": "remove", "key": key, "value": value, "reason": str(set_attrs.get("reason", "")).strip() }) # for sm in re.finditer(r"]*?/\s*>", body, re.IGNORECASE): set_attrs = parse_xml_attributes(sm.group(0)) key = str(set_attrs.get("key", "")).strip() delta_str = str(set_attrs.get("delta", "1")).strip() if not key: continue try: delta = int(delta_str) except ValueError: delta = 1 operations.append({ "op": "inc", "key": key, "delta": delta, "reason": str(set_attrs.get("reason", "")).strip() }) if not operations: continue results.append( { "id": str(attrs.get("id", "")).strip() or None, "ref": str(attrs.get("ref", "")).strip() or None, "type": str(attrs.get("type", "")).strip() or None, "operations": operations, "line": _line_number_from_index(text, m.start()), "source_file": file_path, } ) return results def extract_golden_finger_skills(file_path: str) -> List[Dict]: """ 从章节文件中提取金手指技能标签(v4.0 仅支持 XML 格式) XML 格式: 示例: Returns: List[Dict]: [{"name": "吞噬", "level": "Lv1", "desc": "...", "cooldown": "10秒"}, ...] """ skills = [] with open(file_path, 'r', encoding='utf-8') as f: for line_num, line in enumerate(f, 1): xml_matches = re.findall( r'', line ) for match in xml_matches: skills.append({ "name": match[0].strip(), "level": match[1].strip(), "desc": match[2].strip(), "cooldown": match[3].strip(), "line": line_num, "source_file": file_path }) return skills def extract_foreshadowing_json(file_path: str) -> List[Dict[str, Any]]: """ 从章节文件提取伏笔标签(v4.0 仅支持 XML 格式) XML 格式: 示例: 字段: - content (必填) - tier (可选: 核心/支线/装饰,默认 支线) - planted_chapter (可选: 默认由调用方补齐) - target_chapter / target (可选: 默认 planted_chapter + 100) - location (可选) - characters (可选: 逗号分隔字符串) """ p = Path(file_path) text = p.read_text(encoding="utf-8") results: List[Dict[str, Any]] = [] xml_pattern = re.compile( r'', re.DOTALL ) for m in xml_pattern.finditer(text): line_num = text[: m.start()].count("\n") + 1 content = m.group(1).strip() if not content: continue tier = m.group(2).strip() or "支线" if tier.lower() not in ENTITY_TIER_MAP: tier = "支线" target_str = m.group(3) target_chapter = None if target_str: try: target_chapter = int(target_str.strip()) except (TypeError, ValueError): pass location = (m.group(4) or "").strip() characters_str = m.group(5) or "" characters_list = [c.strip() for c in re.split(r"[,,]", characters_str) if c.strip()] results.append({ "content": content, "tier": tier, "planted_chapter": None, "target_chapter": target_chapter, "location": location, "characters": characters_list, "line": line_num, "source_file": str(p), }) return results def extract_deviations(file_path: str) -> List[Dict[str, Any]]: """ 从章节文件提取大纲偏离标签(v4.0 仅支持 XML 格式) XML 格式: 示例: Returns: List[Dict]: [{"reason": "...", "line": 123}, ...] """ p = Path(file_path) text = p.read_text(encoding="utf-8") results: List[Dict[str, Any]] = [] xml_pattern = re.compile( r'', re.DOTALL ) for m in xml_pattern.finditer(text): line_num = text[: m.start()].count("\n") + 1 reason = m.group(1).strip() if reason: results.append({ "reason": reason, "line": line_num, "source_file": str(p), }) return results def extract_relationships(file_path: str) -> List[Dict[str, Any]]: """ 从章节文件提取角色关系标签 XML 格式(推荐使用 entity_id,避免改名导致断链): 示例: 关系类型 (type): - ally: 盟友 - enemy: 敌人 - romance: 恋人/暧昧 - mentor: 师徒 - debtor: 恩怨(欠人情/被欠) - family: 家族/血缘 - rival: 竞争对手 强度 (intensity): 0-100,越高关系越强烈 Returns: List[Dict]: [{"char1","char2","char1_id?","char2_id?","type","intensity","desc",...}, ...] """ p = Path(file_path) text = p.read_text(encoding="utf-8") results: List[Dict[str, Any]] = [] valid_types = {"ally", "enemy", "romance", "mentor", "debtor", "family", "rival"} # XML 格式: xml_pattern = re.compile(r"]*?/\s*>", re.IGNORECASE) for m in xml_pattern.finditer(text): line_num = text[: m.start()].count("\n") + 1 attrs = parse_xml_attributes(m.group(0)) char1 = str(attrs.get("char1", "")).strip() char2 = str(attrs.get("char2", "")).strip() char1_id = str(attrs.get("char1_id", "")).strip() or None char2_id = str(attrs.get("char2_id", "")).strip() or None rel_type = str(attrs.get("type", "")).strip().lower() or "ally" intensity_str = str(attrs.get("intensity", "")).strip() or "50" desc = str(attrs.get("desc", "")).strip() if not ((char1_id or char1) and (char2_id or char2)): continue # 验证关系类型 if rel_type not in valid_types: print(f"⚠️ 未知关系类型 '{rel_type}'(第{line_num}行),使用默认 'ally'") rel_type = "ally" # 解析强度 try: intensity = int(intensity_str) intensity = max(0, min(100, intensity)) # 限制 0-100 except ValueError: intensity = 50 # 默认中等强度 results.append({ "char1": char1, "char2": char2, "char1_id": char1_id, "char2_id": char2_id, "type": rel_type, "intensity": intensity, "desc": desc, "line": line_num, "source_file": str(p), }) return results def categorize_character(desc: str) -> str: """ 根据描述判断角色分类 规则: - 包含"主角"/"林天" → 主要角色 - 包含"反派"/"敌对"/"血煞门" → 反派角色 - 其他 → 次要角色 """ if "主角" in desc or "重要" in desc: return "主要角色" elif "反派" in desc or "敌对" in desc or "血煞" in desc: return "反派角色" else: return "次要角色" def generate_character_card(entity: Dict, category: str) -> str: """生成角色卡 Markdown 内容""" return f"""# {entity['name']} > **首次登场**: {entity.get('source_file', '未知')}(第 {entity.get('line', '?')} 行) > **创建时间**: {datetime.now().strftime('%Y-%m-%d')} ## 基本信息 - **姓名**: {entity['name']} - **性别**: 待补充 - **年龄**: 待补充 - **身份**: {entity['desc']} - **所属势力**: 待补充 ## 实力设定 - **当前境界**: 待补充 - **擅长招式**: 待补充 - **特殊能力**: 待补充 ## 性格特点 {entity['desc']} ## 外貌描述 待补充 ## 人际关系 - **与主角**: 待补充 ## 重要剧情 - 【第 X 章】{entity['desc']} ## 备注 自动提取自 `` 标签,请补充完善。 """ def update_world_view(entity: Dict, target_file: str, section: str): """更新世界观.md(追加地点/势力信息)""" if not os.path.exists(target_file): # 创建基础模板 content = f"""# 世界观 ## 地理 ## 势力 ## 历史背景 """ with open(target_file, 'w', encoding='utf-8') as f: f.write(content) # 读取现有内容 with open(target_file, 'r', encoding='utf-8') as f: content = f.read() # 追加到对应章节 if section == "地理": entry = f""" ### {entity['name']} {entity['desc']} > 首次登场: {entity.get('source_file', '未知')} """ elif section == "势力": entry = f""" ### {entity['name']} {entity['desc']} > 首次登场: {entity.get('source_file', '未知')} """ # 在对应章节后追加 pattern = f"## {section}" if pattern in content: content = content.replace(pattern, f"{pattern}\n{entry}") else: content += f"\n## {section}\n{entry}" with open(target_file, 'w', encoding='utf-8') as f: f.write(content) def update_power_system(entity: Dict, target_file: str): """更新力量体系.md(追加招式)""" if not os.path.exists(target_file): content = f"""# 力量体系 ## 境界划分 ## 修炼方法 ## 招式库 """ with open(target_file, 'w', encoding='utf-8') as f: f.write(content) with open(target_file, 'r', encoding='utf-8') as f: content = f.read() entry = f""" ### {entity['name']} {entity['desc']} > 首次登场: {entity.get('source_file', '未知')} """ if "## 招式库" in content: content = content.replace("## 招式库", f"## 招式库\n{entry}") else: content += f"\n## 招式库\n{entry}" with open(target_file, 'w', encoding='utf-8') as f: f.write(content) def update_state_json( entities: List[Dict], state_file: str, golden_finger_skills: Optional[List[Dict]] = None, foreshadowing_items: Optional[List[Dict[str, Any]]] = None, relationship_items: Optional[List[Dict[str, Any]]] = None, entity_alias_ops: Optional[List[Dict[str, Any]]] = None, entity_update_ops: Optional[List[Dict[str, Any]]] = None, *, default_planted_chapter: Optional[int] = None, ): """更新 state.json(实体/别名/属性更新 + 金手指/伏笔/关系)。""" def _to_int(value: Any, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default with open(state_file, 'r', encoding='utf-8') as f: state = json.load(f) first_seen_chapter = _to_int(default_planted_chapter, 0) project_root = Path(state_file).resolve().parent.parent # 确保存在金手指技能列表 if 'protagonist_state' not in state: state['protagonist_state'] = {} golden_finger = state['protagonist_state'].get('golden_finger') if not isinstance(golden_finger, dict): golden_finger = {} state['protagonist_state']['golden_finger'] = golden_finger golden_finger.setdefault("name", "") golden_finger.setdefault("level", 1) golden_finger.setdefault("cooldown", 0) golden_finger.setdefault("skills", []) # --- 实体别名/更新系统(entities_v3 + alias_index)--- state = ensure_entities_v3_structure(state) entity_alias_ops = entity_alias_ops or [] entity_update_ops = entity_update_ops or [] touched = set() def _normalize_entity_type(raw: Any) -> str: t = normalize_entity_type(raw) if not t or t not in state.get("entities_v3", {}): return "" return t def _normalize_first_appearance(source_file: Any) -> str: raw = str(source_file or "").strip() if not raw: return "" try: p = Path(raw) if not p.is_absolute(): p = (Path.cwd() / p).resolve() if p == project_root or project_root in p.parents: return str(p.relative_to(project_root)).replace("\\", "/") return str(p).replace("\\", "/") except Exception: return raw.replace("\\", "/") def _resolve_by_id(entity_id: Any, entity_type: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[dict]]: eid = str(entity_id or "").strip() if not eid: return (None, None, None) if entity_type: et = _normalize_entity_type(entity_type) data = state.get("entities_v3", {}).get(et, {}).get(eid) return (et, eid, data) if isinstance(data, dict) else (None, None, None) hits: list[tuple[str, dict]] = [] for et, bucket in (state.get("entities_v3") or {}).items(): if isinstance(bucket, dict) and eid in bucket: data = bucket.get(eid) if isinstance(data, dict): hits.append((et, data)) if len(hits) == 1: return (hits[0][0], eid, hits[0][1]) return (None, None, None) def _resolve_ref(ref: Any, entity_type: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[dict]]: """通过别名/名称解析实体(v4.0 使用一对多 alias_index)""" r = str(ref or "").strip() if not r: return (None, None, None) # 使用新版 resolve_entity_by_alias(支持一对多 + 歧义检测) et_hint = _normalize_entity_type(entity_type) if entity_type else None et, eid, data = resolve_entity_by_alias(r, et_hint, state) if et and eid and isinstance(data, dict): return (et, eid, data) return (None, None, None) def _register_alias(entity_type: str, entity_id: str, alias: Any, *, context: str = "", first_seen: int = 0) -> None: """注册别名到 alias_index(v4.0 一对多版本)""" a = str(alias or "").strip() if not a: return state.setdefault("alias_index", {}) alias_index = state["alias_index"] # 新格式:alias_index[alias] = [{type, id, first_seen_chapter?, context?}, ...] entries = alias_index.get(a) if entries is None: entries = [] if not isinstance(entries, list): raise ValueError( f"alias_index 数据格式错误:期望 alias_index[{a!r}] 为 list[{{type,id,...}}],实际为 {type(entries).__name__}" ) # 检查是否已存在相同的 (type, id) 组合 new_entry: Dict[str, Any] = {"type": entity_type, "id": entity_id} if first_seen: new_entry["first_seen_chapter"] = int(first_seen) if context: new_entry["context"] = context for existing in entries: if existing.get("type") == entity_type and existing.get("id") == entity_id: # 补齐首次出现/上下文(只填空缺) if first_seen and not existing.get("first_seen_chapter"): existing["first_seen_chapter"] = int(first_seen) if context and not existing.get("context"): existing["context"] = context return # 已存在,无需重复注册 # 添加新条目 entries.append(new_entry) alias_index[a] = entries # 同时更新实体的 aliases 列表 data = state.get("entities_v3", {}).get(entity_type, {}).get(entity_id) if not isinstance(data, dict): return data.setdefault("aliases", []) if a not in data["aliases"]: data["aliases"].append(a) def _ensure_v3_entity(entity_type: str, entity_id: str, canonical_name: str, *, tier: str, desc: str, first_appearance: str) -> dict: bucket = state.setdefault("entities_v3", {}).setdefault(entity_type, {}) data = bucket.get(entity_id) if not isinstance(data, dict): data = { "id": entity_id, "canonical_name": canonical_name, "aliases": [], "tier": tier or "支线", "desc": desc or "", "current": {}, "history": [], "created_chapter": first_seen_chapter or 1, "first_appearance": first_appearance or "", } bucket[entity_id] = data if canonical_name and not data.get("canonical_name"): data["canonical_name"] = canonical_name if tier and str(tier).lower() in ENTITY_TIER_MAP: data["tier"] = tier if desc: data["desc"] = desc if first_appearance and not data.get("first_appearance"): data["first_appearance"] = first_appearance data.setdefault("current", {}) data.setdefault("history", []) data.setdefault("aliases", []) return data def _apply_operations(entity_type: str, entity_id: str, data: dict, operations: List[Dict[str, Any]]) -> None: """应用实体更新操作(v4.0 支持 set/unset/add/remove/inc + 顶层字段)""" if not operations: return current = data.setdefault("current", {}) changes: Dict[str, Any] = {} reasons: Dict[str, str] = {} def _rename(new_name: str, reason: str = "") -> None: new_name = str(new_name or "").strip() if not new_name: return old_name = str(data.get("canonical_name", "")).strip() if old_name and old_name != new_name: _register_alias(entity_type, entity_id, old_name, first_seen=first_seen_chapter) data["canonical_name"] = new_name _register_alias(entity_type, entity_id, new_name, first_seen=first_seen_chapter) changes["canonical_name"] = new_name if reason: reasons["canonical_name"] = reason for op_item in operations: op = str(op_item.get("op", "set")).strip().lower() key = str(op_item.get("key", "")).strip() reason = str(op_item.get("reason", "")).strip() if not key: continue # 顶层字段处理 if key in TOP_LEVEL_FIELDS: if op == "set": value = str(op_item.get("value", "")).strip() if key == "canonical_name": _rename(value, reason) elif key == "tier": # 校验 tier 值 if value.lower() in ENTITY_TIER_MAP or value in {"核心", "支线", "装饰"}: if data.get("tier") != value: data["tier"] = value changes["tier"] = value if reason: reasons["tier"] = reason else: print(f"⚠️ 无效 tier 值: {value},跳过") else: if data.get(key) != value: data[key] = value changes[key] = value if reason: reasons[key] = reason elif op == "unset": if key in data: del data[key] changes[key] = None if reason: reasons[key] = reason continue # canonical_name 的特殊别名 if key in {"name", "canonical_name"} and op == "set": value = str(op_item.get("value", "")).strip() _rename(value, reason) continue # current 字段操作 if op == "set": value = str(op_item.get("value", "")).strip() prev = current.get(key) if prev != value: current[key] = value changes[key] = value if reason: reasons[key] = reason elif op == "unset": if key in current: del current[key] changes[key] = None if reason: reasons[key] = reason elif op == "add": value = str(op_item.get("value", "")).strip() if not value: continue arr = current.get(key, []) if not isinstance(arr, list): arr = [arr] if arr else [] if value not in arr: arr.append(value) current[key] = arr changes[key] = arr if reason: reasons[key] = reason elif op == "remove": value = str(op_item.get("value", "")).strip() if not value: continue arr = current.get(key, []) if isinstance(arr, list) and value in arr: arr.remove(value) current[key] = arr changes[key] = arr if reason: reasons[key] = reason elif op == "inc": delta = op_item.get("delta", 1) try: delta = int(delta) except (TypeError, ValueError): delta = 1 prev = current.get(key, 0) try: prev = int(prev) except (TypeError, ValueError): prev = 0 new_val = prev + delta current[key] = new_val changes[key] = new_val if reason: reasons[key] = reason if first_seen_chapter: current["last_chapter"] = max(_to_int(current.get("last_chapter"), 0), first_seen_chapter) if changes: entry: Dict[str, Any] = {"chapter": first_seen_chapter or 0, "changes": changes} if reasons: entry["reasons"] = reasons entry["added_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") data.setdefault("history", []).append(entry) # 1) 处理 / ... for entity in entities or []: entity_type = _normalize_entity_type(entity.get("type", "")) name = str(entity.get("name", "")).strip() if not name: continue raw_id = entity.get("id") entity_id = (str(raw_id).strip() if raw_id is not None else "") or None data: Optional[dict] = None if entity_id: _, _, data = _resolve_by_id(entity_id, entity_type) else: _, rid, rdata = _resolve_ref(name, entity_type) if rid and isinstance(rdata, dict): entity_id = rid data = rdata if not entity_id: existing_ids = set((state.get("entities_v3") or {}).get(entity_type, {}).keys()) entity_id = generate_entity_id(entity_type, name, existing_ids) first_appearance = _normalize_first_appearance(entity.get("source_file", "")) tier = str(entity.get("tier", "支线")).strip() or "支线" if tier.lower() not in ENTITY_TIER_MAP: tier = "支线" desc = str(entity.get("desc", "")).strip() data = _ensure_v3_entity(entity_type, entity_id, name, tier=tier, desc=desc, first_appearance=first_appearance) # canonical name & aliases _register_alias(entity_type, entity_id, str(data.get("canonical_name", "")).strip() or name, first_seen=first_seen_chapter) _register_alias(entity_type, entity_id, name, first_seen=first_seen_chapter) for alias in (entity.get("aliases") or []): _register_alias(entity_type, entity_id, alias, first_seen=first_seen_chapter) # attribute updates (auto mode) extra_attrs = entity.get("attrs") or {} if isinstance(extra_attrs, dict) and extra_attrs: ops = [{"op": "set", "key": k, "value": str(v), "reason": ""} for k, v in extra_attrs.items()] _apply_operations(entity_type, entity_id, data, ops) touched.add((entity_type, entity_id)) # 2) 处理 for op in entity_alias_ops: alias = str(op.get("alias", "")).strip() if not alias: continue hint = op.get("type") entity_type_hint = _normalize_entity_type(hint) if hint else None et: Optional[str] = None eid: Optional[str] = None data: Optional[dict] = None if op.get("id"): et, eid, data = _resolve_by_id(op.get("id"), entity_type_hint) elif op.get("ref"): et, eid, data = _resolve_ref(op.get("ref"), entity_type_hint) if not (et and eid and isinstance(data, dict)): print(f"?? entity-alias 无法解析引用: id={op.get('id')!r} ref={op.get('ref')!r}") continue _register_alias(et, eid, alias, context=str(op.get("context", "")).strip(), first_seen=first_seen_chapter) touched.add((et, eid)) # 3) 处理 ... for op in entity_update_ops: operations = op.get("operations") or [] if not isinstance(operations, list) or not operations: continue hint = op.get("type") entity_type_hint = _normalize_entity_type(hint) if hint else None et: Optional[str] = None eid: Optional[str] = None data: Optional[dict] = None if op.get("id"): et, eid, data = _resolve_by_id(op.get("id"), entity_type_hint) elif op.get("ref"): et, eid, data = _resolve_ref(op.get("ref"), entity_type_hint) if not (et and eid and isinstance(data, dict)): print(f"⚠️ entity-update 无法解析引用: id={op.get('id')!r} ref={op.get('ref')!r}") continue _apply_operations(et, eid, data, operations) touched.add((et, eid)) # 4) 更新金手指技能 if golden_finger_skills: existing = state['protagonist_state']['golden_finger'].get('skills', []) if not isinstance(existing, list): existing = [] state['protagonist_state']['golden_finger']['skills'] = existing existing_by_name = {s.get("name"): s for s in existing if isinstance(s, dict) and s.get("name")} for skill in golden_finger_skills: if not isinstance(skill, dict): continue name = str(skill.get("name", "")).strip() if not name: continue level = str(skill.get("level", "")).strip() desc = str(skill.get("desc", "")).strip() cooldown = str(skill.get("cooldown", "")).strip() source_file = str(skill.get("source_file", "")).strip() existing_skill = existing_by_name.get(name) if existing_skill is None: new_skill = { "name": name, "level": level, "desc": desc, "cooldown": cooldown, "unlocked_at": source_file, "added_at": datetime.now().strftime('%Y-%m-%d') } existing.append(new_skill) existing_by_name[name] = new_skill print(f" ✨ 新增金手指技能: {name} ({level})") continue changed = False if level and existing_skill.get("level") != level: existing_skill["level"] = level changed = True if desc and existing_skill.get("desc") != desc: existing_skill["desc"] = desc changed = True if cooldown and existing_skill.get("cooldown") != cooldown: existing_skill["cooldown"] = cooldown changed = True if source_file and not existing_skill.get("unlocked_at"): existing_skill["unlocked_at"] = source_file changed = True if changed: existing_skill["updated_at"] = datetime.now().strftime('%Y-%m-%d') print(f" 🔁 更新金手指技能: {name} ({existing_skill.get('level', level)})") # 更新伏笔(结构化) if foreshadowing_items: state.setdefault("plot_threads", {"active_threads": [], "foreshadowing": []}) state["plot_threads"].setdefault("foreshadowing", []) existing = state["plot_threads"]["foreshadowing"] for item in foreshadowing_items: content = str(item.get("content", "")).strip() if not content: continue planted = item.get("planted_chapter") or default_planted_chapter or 1 try: planted = int(planted) except (TypeError, ValueError): planted = default_planted_chapter or 1 target = item.get("target_chapter") if target is None: target = planted + 100 try: target = int(target) except (TypeError, ValueError): target = planted + 100 tier = str(item.get("tier", "支线")).strip() or "支线" if tier.lower() not in ENTITY_TIER_MAP: tier = "支线" location = str(item.get("location", "")).strip() characters = item.get("characters", []) if not isinstance(characters, list): characters = [] found = None for old in existing: if old.get("content") == content: found = old break if found is None: existing.append({ "content": content, "status": "未回收", "tier": tier, "planted_chapter": planted, "target_chapter": target, "location": location, "characters": characters, "added_at": datetime.now().strftime("%Y-%m-%d"), }) print(f" ?? 新增伏笔: {content[:30]}...") else: found["tier"] = tier found["planted_chapter"] = planted found["target_chapter"] = target if location: found["location"] = location old_chars = found.get("characters", []) if not isinstance(old_chars, list): old_chars = [] merged = [] seen = set() for n in [*old_chars, *characters]: s = str(n).strip() if not s or s in seen: continue merged.append(s) seen.add(s) found["characters"] = merged # 更新关系(结构化,推荐使用 entity_id) if relationship_items: state.setdefault("structured_relationships", []) existing = state["structured_relationships"] for item in relationship_items: # 优先使用显式 entity_id;否则按别名解析(强制消歧) char1_id = str(item.get("char1_id", "") or "").strip() char2_id = str(item.get("char2_id", "") or "").strip() char1_ref = str(item.get("char1", "")).strip() char2_ref = str(item.get("char2", "")).strip() # relationship 只允许角色 if char1_id: _, rid, rdata = _resolve_by_id(char1_id, "角色") if not rid or not isinstance(rdata, dict): raise ValueError(f"relationship.char1_id 无法解析: {char1_id!r}") char1_id = rid char1_name = str(rdata.get("canonical_name", "")).strip() or char1_ref else: _, rid, rdata = _resolve_ref(char1_ref, "角色") if not rid or not isinstance(rdata, dict): raise ValueError(f"relationship.char1 无法解析: {char1_ref!r}") char1_id = rid char1_name = str(rdata.get("canonical_name", "")).strip() or char1_ref if char2_id: _, rid, rdata = _resolve_by_id(char2_id, "角色") if not rid or not isinstance(rdata, dict): raise ValueError(f"relationship.char2_id 无法解析: {char2_id!r}") char2_id = rid char2_name = str(rdata.get("canonical_name", "")).strip() or char2_ref else: _, rid, rdata = _resolve_ref(char2_ref, "角色") if not rid or not isinstance(rdata, dict): raise ValueError(f"relationship.char2 无法解析: {char2_ref!r}") char2_id = rid char2_name = str(rdata.get("canonical_name", "")).strip() or char2_ref rel_type = str(item.get("type", "ally")).strip().lower() or "ally" intensity = item.get("intensity", 50) desc = str(item.get("desc", "")).strip() try: intensity = int(intensity) intensity = max(0, min(100, intensity)) except (TypeError, ValueError): intensity = 50 # 查找是否已存在相同关系 found = None for old in existing: if ( old.get("char1_id") == char1_id and old.get("char2_id") == char2_id and old.get("type") == rel_type ): found = old break if found is None: existing.append({ "char1_id": char1_id, "char2_id": char2_id, "char1_name": char1_name, "char2_name": char2_name, "type": rel_type, "intensity": intensity, "description": desc, "last_update_chapter": default_planted_chapter or 1, "added_at": datetime.now().strftime("%Y-%m-%d"), }) print(f" 💕 新增关系: {char1_name} ↔ {char2_name} ({rel_type}, 强度 {intensity})") else: # 更新强度和描述 found["intensity"] = intensity found["description"] = desc found["last_update_chapter"] = default_planted_chapter or found.get("last_update_chapter", 1) found.setdefault("char1_name", char1_name) found.setdefault("char2_name", char2_name) print(f" 💕 更新关系: {char1_name} ↔ {char2_name} ({rel_type}, 强度 {intensity})") # 使用集中式原子写入(带 filelock + 自动备份) atomic_write_json(state_file, state, use_lock=True, backup=True) print(f"✅ state.json 已原子化更新(带备份)") def sync_entity_to_settings(entity: Dict, project_root: str, auto_mode: bool = False) -> bool: """ 将实体同步到设定集 Returns: bool: 是否成功同步 """ entity_type = normalize_entity_type(entity.get('type')) entity_name = entity['name'] if entity_type == "角色": category = categorize_character(entity['desc']) category_dir = ROLE_CATEGORY_MAP.get(category.split('/')[0], "次要角色") target_dir = Path(project_root) / f"设定集/角色库/{category_dir}" # ============================================================================ # 安全修复:使用安全目录创建函数(文件权限修复) # ============================================================================ create_secure_directory(str(target_dir)) # ============================================================================ # 安全修复:清理文件名,防止路径遍历 (CWE-22) - P0 CRITICAL # 原代码: target_file = target_dir / f"{entity_name}.md" # 漏洞: entity_name可能包含 "../" 导致目录遍历攻击 # ============================================================================ safe_entity_name = sanitize_filename(entity_name) target_file = target_dir / f"{safe_entity_name}.md" if target_file.exists(): print(f"⚠️ 角色卡已存在: {target_file}") if not auto_mode: choice = input("是否覆盖?(y/n): ") if choice.lower() != 'y': return False with open(target_file, 'w', encoding='utf-8') as f: f.write(generate_character_card(entity, category)) print(f"✅ 已创建角色卡: {target_file}") return True elif entity_type == "地点": target_file = Path(project_root) / "设定集/世界观.md" update_world_view(entity, str(target_file), "地理") print(f"✅ 已更新世界观(地理): {entity_name}") return True elif entity_type == "势力": target_file = Path(project_root) / "设定集/世界观.md" update_world_view(entity, str(target_file), "势力") print(f"✅ 已更新世界观(势力): {entity_name}") return True elif entity_type == "招式": target_file = Path(project_root) / "设定集/力量体系.md" update_power_system(entity, str(target_file)) print(f"✅ 已更新力量体系(招式): {entity_name}") return True elif entity_type == "物品": target_dir = Path(project_root) / "设定集/物品库" # ============================================================================ # 安全修复:使用安全目录创建函数(文件权限修复) # ============================================================================ create_secure_directory(str(target_dir)) # ============================================================================ # 安全修复:清理文件名,防止路径遍历 (CWE-22) - P0 CRITICAL # 原代码: target_file = target_dir / f"{entity_name}.md" # 漏洞: entity_name可能包含 "../" 导致目录遍历攻击 # ============================================================================ safe_entity_name = sanitize_filename(entity_name) target_file = target_dir / f"{safe_entity_name}.md" if target_file.exists(): print(f"⚠️ 物品卡已存在: {target_file}") if not auto_mode: choice = input("是否覆盖?(y/n): ") if choice.lower() != 'y': return False content = f"""# {entity_name} > **首次登场**: {entity.get('source_file', '未知')} > **创建时间**: {datetime.now().strftime('%Y-%m-%d')} ## 基本信息 {entity['desc']} ## 详细设定 待补充 ## 相关剧情 - 【第 X 章】首次出现 ## 备注 自动提取自 `` 标签,请补充完善。 """ with open(target_file, 'w', encoding='utf-8') as f: f.write(content) print(f"✅ 已创建物品卡: {target_file}") return True else: print(f"⚠️ 未知实体类型: {entity_type}") return False def main(): parser = argparse.ArgumentParser( description="XML 标签提取与同步 (, , , , , , )", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 指定文件(兼容卷目录) python extract_entities.py "webnovel-project/正文/第1卷/第001章-死亡降临.md" --auto # 指定章节号(推荐) python extract_entities.py --project-root "webnovel-project" --chapter 1 --auto """.strip(), ) parser.add_argument("chapter_file", nargs="?", help="章节文件路径(或使用 --chapter)") parser.add_argument("--chapter", type=int, help="章节号(与 --project-root 配合,自动定位章节文件)") parser.add_argument("--project-root", default=None, help="项目根目录(包含 .webnovel/state.json)") parser.add_argument("--auto", action="store_true", help="自动模式(非交互)") parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入文件/状态") args = parser.parse_args() auto_mode = args.auto dry_run = args.dry_run project_root: Optional[Path] = None if args.project_root: project_root = resolve_project_root(args.project_root) else: try: project_root = resolve_project_root() except FileNotFoundError: project_root = None chapter_file: Optional[str] = None chapter_num: Optional[int] = None if args.chapter is not None: if not project_root: print("❌ 未提供有效的 --project-root,无法用 --chapter 定位章节文件") sys.exit(1) chapter_num = int(args.chapter) chapter_path = find_chapter_file(project_root, chapter_num) if not chapter_path: print(f"❌ 未找到第{chapter_num}章文件(请先生成/保存章节)") sys.exit(1) chapter_file = str(chapter_path) else: if not args.chapter_file: parser.error("必须提供 chapter_file 或 --chapter") chapter_file = args.chapter_file if not os.path.exists(chapter_file): print(f"❌ 文件不存在: {chapter_file}") sys.exit(1) chapter_num = extract_chapter_num_from_filename(Path(chapter_file).name) print(f"📖 正在扫描: {chapter_file}") entities = extract_new_entities(chapter_file) entity_alias_ops = extract_entity_alias_ops(chapter_file) entity_update_ops = extract_entity_update_ops(chapter_file) golden_finger_skills = extract_golden_finger_skills(chapter_file) foreshadowing_items = extract_foreshadowing_json(chapter_file) deviations = extract_deviations(chapter_file) relationship_items = extract_relationships(chapter_file) if not entities and not entity_alias_ops and not entity_update_ops and not golden_finger_skills and not foreshadowing_items and not deviations and not relationship_items: print("✅ 未发现任何 XML 标签(//////)") return if entities: print(f"\n🔍 发现 {len(entities)} 个新实体:") for i, entity in enumerate(entities, 1): tier_emoji = {"核心": "🔴", "支线": "🟡", "装饰": "🟢"}.get(entity.get("tier", "支线"), "⚪") print( f" {i}. [{entity['type']}] {entity['name']} {tier_emoji}{entity.get('tier', '支线')} - {entity['desc'][:25]}..." ) if golden_finger_skills: print(f"\n✨ 发现 {len(golden_finger_skills)} 个金手指技能:") for i, skill in enumerate(golden_finger_skills, 1): print(f" {i}. {skill['name']} ({skill['level']}) - {skill['desc'][:25]}...") if entity_alias_ops: print(f"\n🏷️ 发现 {len(entity_alias_ops)} 条实体别名:") for i, op in enumerate(entity_alias_ops, 1): ref = op.get("id") or op.get("ref") or "?" print(f" {i}. {ref} -> {op.get('alias', '')}") if entity_update_ops: print(f"\n🛠️ 发现 {len(entity_update_ops)} 条实体更新:") for i, op in enumerate(entity_update_ops, 1): ref = op.get("id") or op.get("ref") or "?" operations = op.get("operations") or [] ops_preview = [] for o in operations[:6]: if isinstance(o, dict): op_type = o.get("op", "set") key = o.get("key", "") ops_preview.append(f"{op_type}:{key}") preview = ", ".join(ops_preview) + ("..." if len(operations) > 6 else "") print(f" {i}. {ref}: {preview}") if foreshadowing_items: print(f"\n🧩 发现 {len(foreshadowing_items)} 条伏笔:") for i, item in enumerate(foreshadowing_items, 1): tier = item.get("tier", "支线") target = item.get("target_chapter", "未设定") print(f" {i}. {tier} → 目标Ch{target}: {str(item.get('content', ''))[:40]}...") if deviations: print(f"\n⚡ 发现 {len(deviations)} 条大纲偏离:") for i, dev in enumerate(deviations, 1): print(f" {i}. {dev.get('reason', '')[:50]}...") if relationship_items: print(f"\n💕 发现 {len(relationship_items)} 条关系:") for i, rel in enumerate(relationship_items, 1): char1 = str(rel.get("char1") or rel.get("char1_id") or "").strip() or "?" char2 = str(rel.get("char2") or rel.get("char2_id") or "").strip() or "?" print(f" {i}. {char1} ↔ {char2} ({rel['type']}, 强度 {rel['intensity']})") if dry_run: print("\n⚠️ Dry-run 模式,不执行实际写入") return if not project_root: chapter_path = Path(chapter_file).resolve() for parent in [chapter_path.parent] + list(chapter_path.parents): if (parent / ".webnovel" / "state.json").exists(): project_root = parent break if not project_root: print("❌ 找不到项目根目录(缺少 .webnovel/state.json)") print("请先运行 /webnovel-init 初始化项目,或使用 --project-root 指定路径") sys.exit(1) state_file = resolve_state_file(explicit_project_root=str(project_root)) print("\n📝 开始同步到设定集...") success_count = 0 for entity in entities: if sync_entity_to_settings(entity, str(project_root), auto_mode): success_count += 1 print("\n💾 更新 state.json...") try: update_state_json( entities=entities, state_file=str(state_file), golden_finger_skills=golden_finger_skills, foreshadowing_items=foreshadowing_items, relationship_items=relationship_items, entity_alias_ops=entity_alias_ops, entity_update_ops=entity_update_ops, default_planted_chapter=chapter_num, ) except (AmbiguousAliasError, ValueError) as e: print(f"❌ {e}") sys.exit(2) print("\n✅ 完成!") print(f" - 实体同步: {success_count}/{len(entities)} 个") if golden_finger_skills: print(f" - 金手指技能: {len(golden_finger_skills)} 个") if foreshadowing_items: print(f" - 伏笔同步: {len(foreshadowing_items)} 条") if relationship_items: print(f" - 关系同步: {len(relationship_items)} 条") if deviations: print(f" - 大纲偏离: {len(deviations)} 条(仅记录,不同步到 state.json)") if not auto_mode: print("\n💡 建议:") print(" 1. 检查生成的角色卡/物品卡,补充详细设定") print(" 2. 查看 世界观.md 和 力量体系.md 的更新") print(" 3. 确认 .webnovel/state.json 中的实体记录") if golden_finger_skills: print(" 4. 检查金手指技能是否正确记录在 protagonist_state.golden_finger.skills") if foreshadowing_items: print(" 5. 检查 plot_threads.foreshadowing 的 planted/target/tier/location/characters 是否合理") if relationship_items: print(" 6. 检查 structured_relationships 关系记录是否合理") if deviations: print(" 7. 大纲偏离已记录,请在 plan.md 或大纲中同步调整") if __name__ == "__main__": main()