extract_entities.py 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816
  1. #!/usr/bin/env python3
  2. """
  3. XML 标签提取与同步脚本 (v4.0)
  4. > **v5.0 说明**: 此脚本用于**手动标注场景**(可选)。
  5. > v5.0 主流程使用 Data Agent 从纯正文进行 AI 语义提取,不再依赖 XML 标签。
  6. > 如果章节中包含 XML 标签,此脚本仍可用于解析和同步。
  7. 功能:
  8. 1. 扫描指定章节正文,提取所有 XML 格式标签
  9. 2. 支持标签类型:
  10. - <entity>: 实体(角色/地点/物品/势力/招式)
  11. - <entity-alias>: 实体别名注册
  12. - <entity-update>: 实体属性更新(支持 set/unset/add/remove/inc)
  13. - <skill>: 金手指技能
  14. - <foreshadow>: 伏笔标签
  15. - <deviation>: 大纲偏离标记
  16. - <relationship>: 角色关系
  17. 3. 支持实体层级分类(核心/支线/装饰)
  18. 4. 同步到设定集对应文件
  19. 5. 更新 state.json(entities_v3 + alias_index 一对多)
  20. 6. 支持自动化模式和交互式模式
  21. v4.0 变更:
  22. - alias_index 改为一对多(同一别名可映射多个实体)
  23. - 删除旧格式兼容代码
  24. - 新增操作:<unset>/<add>/<remove>/<inc>
  25. - 顶层字段白名单支持
  26. 使用方式:
  27. python extract_entities.py <章节文件> [--auto] [--dry-run]
  28. python extract_entities.py --project-root "path" --chapter 1 --auto
  29. """
  30. import re
  31. import json
  32. import os
  33. import shutil
  34. import sys
  35. import argparse
  36. from pathlib import Path
  37. from datetime import datetime
  38. from typing import List, Dict, Tuple, Optional, Any
  39. # ============================================================================
  40. # 安全修复:导入安全工具函数(P0 CRITICAL)
  41. # ============================================================================
  42. from security_utils import sanitize_filename, create_secure_directory, atomic_write_json
  43. from project_locator import resolve_project_root, resolve_state_file
  44. from chapter_paths import find_chapter_file, extract_chapter_num_from_filename
  45. # Windows 编码兼容性修复
  46. if sys.platform == 'win32':
  47. import io
  48. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
  49. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
  50. # 实体类型与目标文件映射
  51. ENTITY_TYPE_MAP = {
  52. "角色": "设定集/角色库/{category}/{name}.md",
  53. "地点": "设定集/世界观.md",
  54. "物品": "设定集/物品库/{name}.md",
  55. "势力": "设定集/世界观.md",
  56. "招式": "设定集/力量体系.md",
  57. "其他": "设定集/其他设定/{name}.md"
  58. }
  59. # 有效实体类型(v4.0 不再兼容旧别名)
  60. VALID_ENTITY_TYPES = {"角色", "地点", "物品", "势力", "招式"}
  61. # 顶层字段白名单(可通过 entity-update 直接修改)
  62. TOP_LEVEL_FIELDS = {"tier", "desc", "canonical_name", "importance", "status", "parent"}
  63. class AmbiguousAliasError(RuntimeError):
  64. """别名命中多个实体且无法消歧(必须改用 id 或补充 type)。"""
  65. def normalize_entity_type(raw: Any) -> str:
  66. """验证实体类型(v4.0 不再支持别名转换)。"""
  67. t = str(raw or "").strip()
  68. if not t:
  69. return ""
  70. if t in VALID_ENTITY_TYPES:
  71. return t
  72. return "" # 无效类型返回空
  73. # 角色分类规则
  74. ROLE_CATEGORY_MAP = {
  75. "主角": "主要角色",
  76. "配角": "次要角色",
  77. "反派": "反派角色",
  78. "路人": "次要角色"
  79. }
  80. # 实体层级权重(匹配伏笔三层级系统)
  81. ENTITY_TIER_MAP = {
  82. "核心": {"weight": 3.0, "desc": "必须追踪,影响主线"},
  83. "core": {"weight": 3.0, "desc": "必须追踪,影响主线"},
  84. "支线": {"weight": 2.0, "desc": "应该追踪,丰富剧情"},
  85. "sub": {"weight": 2.0, "desc": "应该追踪,丰富剧情"},
  86. "装饰": {"weight": 1.0, "desc": "可选追踪,增加真实感"},
  87. "decor": {"weight": 1.0, "desc": "可选追踪,增加真实感"}
  88. }
  89. # ============================================================================
  90. # 实体管理核心函数 (v3.0 新增)
  91. # ============================================================================
  92. def generate_entity_id(entity_type: str, name: str, existing_ids: set) -> str:
  93. """
  94. 生成唯一实体 ID
  95. 规则:
  96. 1. 优先使用拼音(去空格、小写)
  97. 2. 冲突时追加数字后缀
  98. 3. 特殊前缀按类型
  99. Args:
  100. entity_type: 实体类型(角色/地点/物品/势力/招式)
  101. name: 实体名称
  102. existing_ids: 已存在的 ID 集合
  103. Returns:
  104. str: 唯一的实体 ID
  105. """
  106. # 类型前缀映射
  107. prefix_map = {
  108. "物品": "item_",
  109. "势力": "faction_",
  110. "招式": "skill_",
  111. "地点": "loc_"
  112. # 角色无前缀
  113. }
  114. # 尝试使用 pypinyin,如果不可用则用简单的 hash
  115. try:
  116. from pypinyin import lazy_pinyin
  117. pinyin = ''.join(lazy_pinyin(name))
  118. base_id = prefix_map.get(entity_type, '') + pinyin.lower()
  119. except ImportError:
  120. # pypinyin 不可用时,使用简化方案
  121. import hashlib
  122. hash_suffix = hashlib.md5(name.encode('utf-8')).hexdigest()[:8]
  123. base_id = prefix_map.get(entity_type, '') + hash_suffix
  124. # 清理非法字符
  125. base_id = re.sub(r'[^a-z0-9_]', '', base_id)
  126. # 处理冲突
  127. final_id = base_id
  128. counter = 1
  129. while final_id in existing_ids:
  130. final_id = f"{base_id}_{counter}"
  131. counter += 1
  132. return final_id
  133. def resolve_entity_by_alias(alias: str, entity_type: Optional[str], state: dict) -> Tuple[Optional[str], Optional[str], Optional[dict]]:
  134. """
  135. 通过别名解析实体(v4.0 一对多版本)
  136. Args:
  137. alias: 别名或名称
  138. entity_type: 实体类型提示(可选,用于歧义消解)
  139. state: state.json 内容
  140. Returns:
  141. (entity_type, entity_id, entity_data) 或 (None, None, None)
  142. Raises:
  143. AmbiguousAliasError: 别名命中多个实体且无法消歧(必须改用 id 或补充 type)
  144. ValueError: alias_index 数据格式不符合 v4.0 规范
  145. """
  146. alias_index = state.get("alias_index", {})
  147. # alias_index 新格式: {"别名": [{"type": "角色", "id": "xxx"}, ...]}
  148. entries = alias_index.get(alias)
  149. if not entries:
  150. return (None, None, None)
  151. if not isinstance(entries, list):
  152. raise ValueError(
  153. f"alias_index 数据格式错误:期望 alias_index[{alias!r}] 为 list[{{type,id,...}}],实际为 {type(entries).__name__}"
  154. )
  155. # 只有一个匹配 -> 直接返回
  156. if len(entries) == 1:
  157. ref = entries[0]
  158. et = ref.get("type", "")
  159. eid = ref.get("id", "")
  160. entities_v3 = state.get("entities_v3", {})
  161. entity_data = entities_v3.get(et, {}).get(eid)
  162. return (et, eid, entity_data) if entity_data else (None, None, None)
  163. # 多个匹配 -> 尝试用 type 消解
  164. if entity_type:
  165. matches = [e for e in entries if e.get("type") == entity_type]
  166. if len(matches) == 1:
  167. ref = matches[0]
  168. et = ref.get("type", "")
  169. eid = ref.get("id", "")
  170. entities_v3 = state.get("entities_v3", {})
  171. entity_data = entities_v3.get(et, {}).get(eid)
  172. return (et, eid, entity_data) if entity_data else (None, None, None)
  173. # 歧义无法消解:必须强制报错,避免写错实体
  174. raise AmbiguousAliasError(f"别名歧义: {alias!r} 命中 {len(entries)} 个实体,请改用 id 或补充 type 属性")
  175. def ensure_entities_v3_structure(state: dict) -> dict:
  176. """
  177. 确保 state.json 有 entities_v3 和 alias_index 结构
  178. entities_v3 格式:
  179. {
  180. "角色": {
  181. "lintian": {
  182. "id": "lintian",
  183. "canonical_name": "林天",
  184. "aliases": ["废物", "林天"],
  185. "tier": "核心",
  186. "current": {...},
  187. "history": [...],
  188. "created_chapter": 1
  189. }
  190. },
  191. "地点": {...},
  192. ...
  193. }
  194. alias_index 格式 (v4.0 一对多):
  195. {
  196. "废物": [{"type": "角色", "id": "lintian"}],
  197. "天云宗": [
  198. {"type": "地点", "id": "loc_tianyunzong"},
  199. {"type": "势力", "id": "faction_tianyunzong"}
  200. ],
  201. ...
  202. }
  203. """
  204. if "entities_v3" not in state:
  205. state["entities_v3"] = {
  206. "角色": {},
  207. "地点": {},
  208. "物品": {},
  209. "势力": {},
  210. "招式": {}
  211. }
  212. if "alias_index" not in state:
  213. state["alias_index"] = {}
  214. return state
  215. _XML_ATTR_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(["\'])(.*?)\2', re.DOTALL)
  216. def parse_xml_attributes(tag: str) -> Dict[str, str]:
  217. """从形如 `<tag a=\"1\" b='2'/>` 的片段中提取属性字典(不做 XML 语义校验)。"""
  218. attrs: Dict[str, str] = {}
  219. for m in _XML_ATTR_RE.finditer(tag):
  220. key = m.group(1).strip()
  221. value = m.group(3).strip()
  222. if not key:
  223. continue
  224. attrs[key] = value
  225. return attrs
  226. def _line_number_from_index(text: str, index: int) -> int:
  227. return text[:index].count("\n") + 1
  228. def extract_new_entities(file_path: str) -> List[Dict]:
  229. """
  230. 从章节文件中提取所有实体标签(v4.0 仅支持 XML 格式)。
  231. 支持 XML 形态:
  232. 1) 自闭合:<entity type="角色" name="林天" desc="..." tier="核心" [id="lintian"] [任意属性...]/>
  233. 2) 成对:
  234. <entity type="角色" id="lintian" name="林天" desc="..." tier="核心">
  235. <alias>废物</alias>
  236. <alias>林宗主</alias>
  237. </entity>
  238. Returns:
  239. List[Dict]: [{"type","name","desc","tier","id?","attrs","aliases","line","source_file"}, ...]
  240. """
  241. p = Path(file_path)
  242. text = p.read_text(encoding="utf-8")
  243. entities: List[Dict[str, Any]] = []
  244. # ============================================================
  245. # XML 成对格式: <entity ...> ... </entity>(用于内嵌 alias)
  246. # ============================================================
  247. block_pattern = re.compile(r"(?s)(<entity\b[^>]*>)(.*?)</entity>")
  248. for m in block_pattern.finditer(text):
  249. open_tag = m.group(1)
  250. body = m.group(2)
  251. attrs = parse_xml_attributes(open_tag)
  252. entity_type = str(attrs.get("type", "")).strip()
  253. entity_name = str(attrs.get("name", "")).strip()
  254. if not entity_type or not entity_name:
  255. continue
  256. # 验证 entity_type
  257. if entity_type not in VALID_ENTITY_TYPES:
  258. print(f"⚠️ 无效实体类型: {entity_type}(第{_line_number_from_index(text, m.start())}行),跳过")
  259. continue
  260. entity_desc = str(attrs.get("desc", "")).strip()
  261. entity_tier = str(attrs.get("tier", "支线")).strip() or "支线"
  262. if entity_tier.lower() not in ENTITY_TIER_MAP:
  263. entity_tier = "支线"
  264. entity_id = str(attrs.get("id", "")).strip() or None
  265. extra_attrs = {k: v for k, v in attrs.items() if k not in {"type", "id", "name", "desc", "tier"}}
  266. aliases = [a.strip() for a in re.findall(r"(?s)<alias>(.*?)</alias>", body) if str(a).strip()]
  267. entities.append(
  268. {
  269. "type": entity_type,
  270. "id": entity_id,
  271. "name": entity_name,
  272. "desc": entity_desc,
  273. "tier": entity_tier,
  274. "attrs": extra_attrs,
  275. "aliases": aliases,
  276. "line": _line_number_from_index(text, m.start()),
  277. "source_file": file_path,
  278. }
  279. )
  280. # ============================================================
  281. # XML 自闭合格式: <entity .../>
  282. # ============================================================
  283. self_closing_pattern = re.compile(r"<entity\b[^>]*?/\s*>")
  284. for m in self_closing_pattern.finditer(text):
  285. tag = m.group(0)
  286. attrs = parse_xml_attributes(tag)
  287. entity_type = str(attrs.get("type", "")).strip()
  288. entity_name = str(attrs.get("name", "")).strip()
  289. if not entity_type or not entity_name:
  290. continue
  291. # 验证 entity_type
  292. if entity_type not in VALID_ENTITY_TYPES:
  293. print(f"⚠️ 无效实体类型: {entity_type}(第{_line_number_from_index(text, m.start())}行),跳过")
  294. continue
  295. entity_desc = str(attrs.get("desc", "")).strip()
  296. entity_tier = str(attrs.get("tier", "支线")).strip() or "支线"
  297. if entity_tier.lower() not in ENTITY_TIER_MAP:
  298. entity_tier = "支线"
  299. entity_id = str(attrs.get("id", "")).strip() or None
  300. extra_attrs = {k: v for k, v in attrs.items() if k not in {"type", "id", "name", "desc", "tier"}}
  301. entities.append(
  302. {
  303. "type": entity_type,
  304. "id": entity_id,
  305. "name": entity_name,
  306. "desc": entity_desc,
  307. "tier": entity_tier,
  308. "attrs": extra_attrs,
  309. "aliases": [],
  310. "line": _line_number_from_index(text, m.start()),
  311. "source_file": file_path,
  312. }
  313. )
  314. return entities
  315. def extract_entity_alias_ops(file_path: str) -> List[Dict[str, Any]]:
  316. """
  317. 提取实体别名操作:
  318. <entity-alias id="lintian" alias="林宗主" context="成为宗主后"/>
  319. <entity-alias ref="林天" alias="不灭战神" context="晋升称号后"/>
  320. 可选:type="角色|地点|物品|势力|招式" 用于 disambiguation。
  321. """
  322. p = Path(file_path)
  323. text = p.read_text(encoding="utf-8")
  324. results: List[Dict[str, Any]] = []
  325. pattern = re.compile(r"<entity[-_]alias\b[^>]*?/\s*>", re.IGNORECASE)
  326. for m in pattern.finditer(text):
  327. tag = m.group(0)
  328. attrs = parse_xml_attributes(tag)
  329. alias = str(attrs.get("alias", "")).strip()
  330. if not alias:
  331. continue
  332. results.append(
  333. {
  334. "id": str(attrs.get("id", "")).strip() or None,
  335. "ref": str(attrs.get("ref", "")).strip() or None,
  336. "type": str(attrs.get("type", "")).strip() or None,
  337. "alias": alias,
  338. "context": str(attrs.get("context", "")).strip(),
  339. "line": _line_number_from_index(text, m.start()),
  340. "source_file": file_path,
  341. }
  342. )
  343. return results
  344. def extract_entity_update_ops(file_path: str) -> List[Dict[str, Any]]:
  345. """
  346. 提取实体更新操作(v4.0 支持 set/unset/add/remove/inc):
  347. <entity-update id="lintian">
  348. <set key="realm" value="筑基期一层" reason="突破"/>
  349. <unset key="bottleneck"/>
  350. <add key="titles" value="不灭战神"/>
  351. <remove key="allies" value="张三"/>
  352. <inc key="kill_count" delta="1"/>
  353. </entity-update>
  354. <entity-update ref="林宗主" type="角色">
  355. <set key="realm" value="金丹期"/>
  356. </entity-update>
  357. 可选:type="角色|地点|物品|势力|招式" 用于 disambiguation。
  358. """
  359. p = Path(file_path)
  360. text = p.read_text(encoding="utf-8")
  361. results: List[Dict[str, Any]] = []
  362. block_pattern = re.compile(r"(?s)(<entity-update\b[^>]*>)(.*?)</entity-update>", re.IGNORECASE)
  363. for m in block_pattern.finditer(text):
  364. open_tag = m.group(1)
  365. body = m.group(2)
  366. attrs = parse_xml_attributes(open_tag)
  367. operations: List[Dict[str, Any]] = []
  368. # <set key="..." value="..." reason="..."/>
  369. for sm in re.finditer(r"<set\b[^>]*?/\s*>", body, re.IGNORECASE):
  370. set_attrs = parse_xml_attributes(sm.group(0))
  371. key = str(set_attrs.get("key", "")).strip()
  372. value = str(set_attrs.get("value", "")).strip()
  373. if not key:
  374. continue
  375. operations.append({
  376. "op": "set",
  377. "key": key,
  378. "value": value,
  379. "reason": str(set_attrs.get("reason", "")).strip()
  380. })
  381. # <unset key="..."/>
  382. for sm in re.finditer(r"<unset\b[^>]*?/\s*>", body, re.IGNORECASE):
  383. set_attrs = parse_xml_attributes(sm.group(0))
  384. key = str(set_attrs.get("key", "")).strip()
  385. if not key:
  386. continue
  387. operations.append({
  388. "op": "unset",
  389. "key": key,
  390. "reason": str(set_attrs.get("reason", "")).strip()
  391. })
  392. # <add key="..." value="..."/>
  393. for sm in re.finditer(r"<add\b[^>]*?/\s*>", body, re.IGNORECASE):
  394. set_attrs = parse_xml_attributes(sm.group(0))
  395. key = str(set_attrs.get("key", "")).strip()
  396. value = str(set_attrs.get("value", "")).strip()
  397. if not key or not value:
  398. continue
  399. operations.append({
  400. "op": "add",
  401. "key": key,
  402. "value": value,
  403. "reason": str(set_attrs.get("reason", "")).strip()
  404. })
  405. # <remove key="..." value="..."/>
  406. for sm in re.finditer(r"<remove\b[^>]*?/\s*>", body, re.IGNORECASE):
  407. set_attrs = parse_xml_attributes(sm.group(0))
  408. key = str(set_attrs.get("key", "")).strip()
  409. value = str(set_attrs.get("value", "")).strip()
  410. if not key or not value:
  411. continue
  412. operations.append({
  413. "op": "remove",
  414. "key": key,
  415. "value": value,
  416. "reason": str(set_attrs.get("reason", "")).strip()
  417. })
  418. # <inc key="..." delta="..."/>
  419. for sm in re.finditer(r"<inc\b[^>]*?/\s*>", body, re.IGNORECASE):
  420. set_attrs = parse_xml_attributes(sm.group(0))
  421. key = str(set_attrs.get("key", "")).strip()
  422. delta_str = str(set_attrs.get("delta", "1")).strip()
  423. if not key:
  424. continue
  425. try:
  426. delta = int(delta_str)
  427. except ValueError:
  428. delta = 1
  429. operations.append({
  430. "op": "inc",
  431. "key": key,
  432. "delta": delta,
  433. "reason": str(set_attrs.get("reason", "")).strip()
  434. })
  435. if not operations:
  436. continue
  437. results.append(
  438. {
  439. "id": str(attrs.get("id", "")).strip() or None,
  440. "ref": str(attrs.get("ref", "")).strip() or None,
  441. "type": str(attrs.get("type", "")).strip() or None,
  442. "operations": operations,
  443. "line": _line_number_from_index(text, m.start()),
  444. "source_file": file_path,
  445. }
  446. )
  447. return results
  448. def extract_golden_finger_skills(file_path: str) -> List[Dict]:
  449. """
  450. 从章节文件中提取金手指技能标签(v4.0 仅支持 XML 格式)
  451. XML 格式:
  452. <skill name="技能名" level="等级" desc="描述" cooldown="冷却时间"/>
  453. 示例:
  454. <skill name="时间回溯" level="1" desc="回到10秒前的状态" cooldown="24小时"/>
  455. Returns:
  456. List[Dict]: [{"name": "吞噬", "level": "Lv1", "desc": "...", "cooldown": "10秒"}, ...]
  457. """
  458. skills = []
  459. with open(file_path, 'r', encoding='utf-8') as f:
  460. for line_num, line in enumerate(f, 1):
  461. xml_matches = re.findall(
  462. r'<skill\s+name=["\']([^"\']+)["\']\s+level=["\']([^"\']+)["\']\s+desc=["\']([^"\']+)["\']\s+cooldown=["\']([^"\']+)["\']\s*/?>',
  463. line
  464. )
  465. for match in xml_matches:
  466. skills.append({
  467. "name": match[0].strip(),
  468. "level": match[1].strip(),
  469. "desc": match[2].strip(),
  470. "cooldown": match[3].strip(),
  471. "line": line_num,
  472. "source_file": file_path
  473. })
  474. return skills
  475. def extract_foreshadowing_json(file_path: str) -> List[Dict[str, Any]]:
  476. """
  477. 从章节文件提取伏笔标签(v4.0 仅支持 XML 格式)
  478. XML 格式:
  479. <foreshadow content="伏笔内容" tier="层级" target="目标章节" location="地点" characters="角色1,角色2"/>
  480. 示例:
  481. <foreshadow content="神秘老者留下的玉佩开始发光" tier="核心" target="50" location="废弃实验室" characters="陆辰"/>
  482. 字段:
  483. - content (必填)
  484. - tier (可选: 核心/支线/装饰,默认 支线)
  485. - planted_chapter (可选: 默认由调用方补齐)
  486. - target_chapter / target (可选: 默认 planted_chapter + 100)
  487. - location (可选)
  488. - characters (可选: 逗号分隔字符串)
  489. """
  490. p = Path(file_path)
  491. text = p.read_text(encoding="utf-8")
  492. results: List[Dict[str, Any]] = []
  493. xml_pattern = re.compile(
  494. r'<foreshadow\s+'
  495. r'content=["\']([^"\']+)["\']\s+'
  496. r'tier=["\']([^"\']+)["\']'
  497. r'(?:\s+target=["\']([^"\']*)["\'])?'
  498. r'(?:\s+location=["\']([^"\']*)["\'])?'
  499. r'(?:\s+characters=["\']([^"\']*)["\'])?'
  500. r'\s*/?>',
  501. re.DOTALL
  502. )
  503. for m in xml_pattern.finditer(text):
  504. line_num = text[: m.start()].count("\n") + 1
  505. content = m.group(1).strip()
  506. if not content:
  507. continue
  508. tier = m.group(2).strip() or "支线"
  509. if tier.lower() not in ENTITY_TIER_MAP:
  510. tier = "支线"
  511. target_str = m.group(3)
  512. target_chapter = None
  513. if target_str:
  514. try:
  515. target_chapter = int(target_str.strip())
  516. except (TypeError, ValueError):
  517. pass
  518. location = (m.group(4) or "").strip()
  519. characters_str = m.group(5) or ""
  520. characters_list = [c.strip() for c in re.split(r"[,,]", characters_str) if c.strip()]
  521. results.append({
  522. "content": content,
  523. "tier": tier,
  524. "planted_chapter": None,
  525. "target_chapter": target_chapter,
  526. "location": location,
  527. "characters": characters_list,
  528. "line": line_num,
  529. "source_file": str(p),
  530. })
  531. return results
  532. def extract_deviations(file_path: str) -> List[Dict[str, Any]]:
  533. """
  534. 从章节文件提取大纲偏离标签(v4.0 仅支持 XML 格式)
  535. XML 格式:
  536. <deviation reason="偏离原因"/>
  537. 示例:
  538. <deviation reason="临时灵感,增加李薇与陆辰的情感互动,为后续感情线铺垫"/>
  539. Returns:
  540. List[Dict]: [{"reason": "...", "line": 123}, ...]
  541. """
  542. p = Path(file_path)
  543. text = p.read_text(encoding="utf-8")
  544. results: List[Dict[str, Any]] = []
  545. xml_pattern = re.compile(
  546. r'<deviation\s+reason=["\']([^"\']+)["\']\s*/?>',
  547. re.DOTALL
  548. )
  549. for m in xml_pattern.finditer(text):
  550. line_num = text[: m.start()].count("\n") + 1
  551. reason = m.group(1).strip()
  552. if reason:
  553. results.append({
  554. "reason": reason,
  555. "line": line_num,
  556. "source_file": str(p),
  557. })
  558. return results
  559. def extract_relationships(file_path: str) -> List[Dict[str, Any]]:
  560. """
  561. 从章节文件提取角色关系标签
  562. XML 格式(推荐使用 entity_id,避免改名导致断链):
  563. <relationship char1_id="lintian" char2_id="lixue" type="romance" intensity="60" desc="暧昧中,互有好感"/>
  564. <relationship char1="林天" char2="李雪" type="romance" intensity="60" desc="暧昧中,互有好感"/>
  565. 示例:
  566. <relationship char1="林天" char2="李雪" type="romance" intensity="60" desc="暧昧中,互有好感"/>
  567. <relationship char1="林天" char2="王少" type="enemy" intensity="90" desc="杀父之仇"/>
  568. <relationship char1="林天" char2="云长老" type="mentor" intensity="80" desc="师徒关系,受其指点"/>
  569. 关系类型 (type):
  570. - ally: 盟友
  571. - enemy: 敌人
  572. - romance: 恋人/暧昧
  573. - mentor: 师徒
  574. - debtor: 恩怨(欠人情/被欠)
  575. - family: 家族/血缘
  576. - rival: 竞争对手
  577. 强度 (intensity): 0-100,越高关系越强烈
  578. Returns:
  579. List[Dict]: [{"char1","char2","char1_id?","char2_id?","type","intensity","desc",...}, ...]
  580. """
  581. p = Path(file_path)
  582. text = p.read_text(encoding="utf-8")
  583. results: List[Dict[str, Any]] = []
  584. valid_types = {"ally", "enemy", "romance", "mentor", "debtor", "family", "rival"}
  585. # XML 格式: <relationship .../>
  586. xml_pattern = re.compile(r"<relationship\b[^>]*?/\s*>", re.IGNORECASE)
  587. for m in xml_pattern.finditer(text):
  588. line_num = text[: m.start()].count("\n") + 1
  589. attrs = parse_xml_attributes(m.group(0))
  590. char1 = str(attrs.get("char1", "")).strip()
  591. char2 = str(attrs.get("char2", "")).strip()
  592. char1_id = str(attrs.get("char1_id", "")).strip() or None
  593. char2_id = str(attrs.get("char2_id", "")).strip() or None
  594. rel_type = str(attrs.get("type", "")).strip().lower() or "ally"
  595. intensity_str = str(attrs.get("intensity", "")).strip() or "50"
  596. desc = str(attrs.get("desc", "")).strip()
  597. if not ((char1_id or char1) and (char2_id or char2)):
  598. continue
  599. # 验证关系类型
  600. if rel_type not in valid_types:
  601. print(f"⚠️ 未知关系类型 '{rel_type}'(第{line_num}行),使用默认 'ally'")
  602. rel_type = "ally"
  603. # 解析强度
  604. try:
  605. intensity = int(intensity_str)
  606. intensity = max(0, min(100, intensity)) # 限制 0-100
  607. except ValueError:
  608. intensity = 50 # 默认中等强度
  609. results.append({
  610. "char1": char1,
  611. "char2": char2,
  612. "char1_id": char1_id,
  613. "char2_id": char2_id,
  614. "type": rel_type,
  615. "intensity": intensity,
  616. "desc": desc,
  617. "line": line_num,
  618. "source_file": str(p),
  619. })
  620. return results
  621. def categorize_character(desc: str) -> str:
  622. """
  623. 根据描述判断角色分类
  624. 规则:
  625. - 包含"主角"/"林天" → 主要角色
  626. - 包含"反派"/"敌对"/"血煞门" → 反派角色
  627. - 其他 → 次要角色
  628. """
  629. if "主角" in desc or "重要" in desc:
  630. return "主要角色"
  631. elif "反派" in desc or "敌对" in desc or "血煞" in desc:
  632. return "反派角色"
  633. else:
  634. return "次要角色"
  635. def generate_character_card(entity: Dict, category: str) -> str:
  636. """生成角色卡 Markdown 内容"""
  637. return f"""# {entity['name']}
  638. > **首次登场**: {entity.get('source_file', '未知')}(第 {entity.get('line', '?')} 行)
  639. > **创建时间**: {datetime.now().strftime('%Y-%m-%d')}
  640. ## 基本信息
  641. - **姓名**: {entity['name']}
  642. - **性别**: 待补充
  643. - **年龄**: 待补充
  644. - **身份**: {entity['desc']}
  645. - **所属势力**: 待补充
  646. ## 实力设定
  647. - **当前境界**: 待补充
  648. - **擅长招式**: 待补充
  649. - **特殊能力**: 待补充
  650. ## 性格特点
  651. {entity['desc']}
  652. ## 外貌描述
  653. 待补充
  654. ## 人际关系
  655. - **与主角**: 待补充
  656. ## 重要剧情
  657. - 【第 X 章】{entity['desc']}
  658. ## 备注
  659. 自动提取自 `<entity/>` 标签,请补充完善。
  660. """
  661. def update_world_view(entity: Dict, target_file: str, section: str):
  662. """更新世界观.md(追加地点/势力信息)"""
  663. if not os.path.exists(target_file):
  664. # 创建基础模板
  665. content = f"""# 世界观
  666. ## 地理
  667. ## 势力
  668. ## 历史背景
  669. """
  670. with open(target_file, 'w', encoding='utf-8') as f:
  671. f.write(content)
  672. # 读取现有内容
  673. with open(target_file, 'r', encoding='utf-8') as f:
  674. content = f.read()
  675. # 追加到对应章节
  676. if section == "地理":
  677. entry = f"""
  678. ### {entity['name']}
  679. {entity['desc']}
  680. > 首次登场: {entity.get('source_file', '未知')}
  681. """
  682. elif section == "势力":
  683. entry = f"""
  684. ### {entity['name']}
  685. {entity['desc']}
  686. > 首次登场: {entity.get('source_file', '未知')}
  687. """
  688. # 在对应章节后追加
  689. pattern = f"## {section}"
  690. if pattern in content:
  691. content = content.replace(pattern, f"{pattern}\n{entry}")
  692. else:
  693. content += f"\n## {section}\n{entry}"
  694. with open(target_file, 'w', encoding='utf-8') as f:
  695. f.write(content)
  696. def update_power_system(entity: Dict, target_file: str):
  697. """更新力量体系.md(追加招式)"""
  698. if not os.path.exists(target_file):
  699. content = f"""# 力量体系
  700. ## 境界划分
  701. ## 修炼方法
  702. ## 招式库
  703. """
  704. with open(target_file, 'w', encoding='utf-8') as f:
  705. f.write(content)
  706. with open(target_file, 'r', encoding='utf-8') as f:
  707. content = f.read()
  708. entry = f"""
  709. ### {entity['name']}
  710. {entity['desc']}
  711. > 首次登场: {entity.get('source_file', '未知')}
  712. """
  713. if "## 招式库" in content:
  714. content = content.replace("## 招式库", f"## 招式库\n{entry}")
  715. else:
  716. content += f"\n## 招式库\n{entry}"
  717. with open(target_file, 'w', encoding='utf-8') as f:
  718. f.write(content)
  719. def update_state_json(
  720. entities: List[Dict],
  721. state_file: str,
  722. golden_finger_skills: Optional[List[Dict]] = None,
  723. foreshadowing_items: Optional[List[Dict[str, Any]]] = None,
  724. relationship_items: Optional[List[Dict[str, Any]]] = None,
  725. entity_alias_ops: Optional[List[Dict[str, Any]]] = None,
  726. entity_update_ops: Optional[List[Dict[str, Any]]] = None,
  727. *,
  728. default_planted_chapter: Optional[int] = None,
  729. ):
  730. """更新 state.json(实体/别名/属性更新 + 金手指/伏笔/关系)。"""
  731. def _to_int(value: Any, default: int = 0) -> int:
  732. try:
  733. return int(value)
  734. except (TypeError, ValueError):
  735. return default
  736. with open(state_file, 'r', encoding='utf-8') as f:
  737. state = json.load(f)
  738. first_seen_chapter = _to_int(default_planted_chapter, 0)
  739. project_root = Path(state_file).resolve().parent.parent
  740. # 确保存在金手指技能列表
  741. if 'protagonist_state' not in state:
  742. state['protagonist_state'] = {}
  743. golden_finger = state['protagonist_state'].get('golden_finger')
  744. if not isinstance(golden_finger, dict):
  745. golden_finger = {}
  746. state['protagonist_state']['golden_finger'] = golden_finger
  747. golden_finger.setdefault("name", "")
  748. golden_finger.setdefault("level", 1)
  749. golden_finger.setdefault("cooldown", 0)
  750. golden_finger.setdefault("skills", [])
  751. # --- 实体别名/更新系统(entities_v3 + alias_index)---
  752. state = ensure_entities_v3_structure(state)
  753. entity_alias_ops = entity_alias_ops or []
  754. entity_update_ops = entity_update_ops or []
  755. touched = set()
  756. def _normalize_entity_type(raw: Any) -> str:
  757. t = normalize_entity_type(raw)
  758. if not t or t not in state.get("entities_v3", {}):
  759. return ""
  760. return t
  761. def _normalize_first_appearance(source_file: Any) -> str:
  762. raw = str(source_file or "").strip()
  763. if not raw:
  764. return ""
  765. try:
  766. p = Path(raw)
  767. if not p.is_absolute():
  768. p = (Path.cwd() / p).resolve()
  769. if p == project_root or project_root in p.parents:
  770. return str(p.relative_to(project_root)).replace("\\", "/")
  771. return str(p).replace("\\", "/")
  772. except Exception:
  773. return raw.replace("\\", "/")
  774. def _resolve_by_id(entity_id: Any, entity_type: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[dict]]:
  775. eid = str(entity_id or "").strip()
  776. if not eid:
  777. return (None, None, None)
  778. if entity_type:
  779. et = _normalize_entity_type(entity_type)
  780. data = state.get("entities_v3", {}).get(et, {}).get(eid)
  781. return (et, eid, data) if isinstance(data, dict) else (None, None, None)
  782. hits: list[tuple[str, dict]] = []
  783. for et, bucket in (state.get("entities_v3") or {}).items():
  784. if isinstance(bucket, dict) and eid in bucket:
  785. data = bucket.get(eid)
  786. if isinstance(data, dict):
  787. hits.append((et, data))
  788. if len(hits) == 1:
  789. return (hits[0][0], eid, hits[0][1])
  790. return (None, None, None)
  791. def _resolve_ref(ref: Any, entity_type: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[dict]]:
  792. """通过别名/名称解析实体(v4.0 使用一对多 alias_index)"""
  793. r = str(ref or "").strip()
  794. if not r:
  795. return (None, None, None)
  796. # 使用新版 resolve_entity_by_alias(支持一对多 + 歧义检测)
  797. et_hint = _normalize_entity_type(entity_type) if entity_type else None
  798. et, eid, data = resolve_entity_by_alias(r, et_hint, state)
  799. if et and eid and isinstance(data, dict):
  800. return (et, eid, data)
  801. return (None, None, None)
  802. def _register_alias(entity_type: str, entity_id: str, alias: Any, *, context: str = "", first_seen: int = 0) -> None:
  803. """注册别名到 alias_index(v4.0 一对多版本)"""
  804. a = str(alias or "").strip()
  805. if not a:
  806. return
  807. state.setdefault("alias_index", {})
  808. alias_index = state["alias_index"]
  809. # 新格式:alias_index[alias] = [{type, id, first_seen_chapter?, context?}, ...]
  810. entries = alias_index.get(a)
  811. if entries is None:
  812. entries = []
  813. if not isinstance(entries, list):
  814. raise ValueError(
  815. f"alias_index 数据格式错误:期望 alias_index[{a!r}] 为 list[{{type,id,...}}],实际为 {type(entries).__name__}"
  816. )
  817. # 检查是否已存在相同的 (type, id) 组合
  818. new_entry: Dict[str, Any] = {"type": entity_type, "id": entity_id}
  819. if first_seen:
  820. new_entry["first_seen_chapter"] = int(first_seen)
  821. if context:
  822. new_entry["context"] = context
  823. for existing in entries:
  824. if existing.get("type") == entity_type and existing.get("id") == entity_id:
  825. # 补齐首次出现/上下文(只填空缺)
  826. if first_seen and not existing.get("first_seen_chapter"):
  827. existing["first_seen_chapter"] = int(first_seen)
  828. if context and not existing.get("context"):
  829. existing["context"] = context
  830. return # 已存在,无需重复注册
  831. # 添加新条目
  832. entries.append(new_entry)
  833. alias_index[a] = entries
  834. # 同时更新实体的 aliases 列表
  835. data = state.get("entities_v3", {}).get(entity_type, {}).get(entity_id)
  836. if not isinstance(data, dict):
  837. return
  838. data.setdefault("aliases", [])
  839. if a not in data["aliases"]:
  840. data["aliases"].append(a)
  841. def _ensure_v3_entity(entity_type: str, entity_id: str, canonical_name: str, *, tier: str, desc: str, first_appearance: str) -> dict:
  842. bucket = state.setdefault("entities_v3", {}).setdefault(entity_type, {})
  843. data = bucket.get(entity_id)
  844. if not isinstance(data, dict):
  845. data = {
  846. "id": entity_id,
  847. "canonical_name": canonical_name,
  848. "aliases": [],
  849. "tier": tier or "支线",
  850. "desc": desc or "",
  851. "current": {},
  852. "history": [],
  853. "created_chapter": first_seen_chapter or 1,
  854. "first_appearance": first_appearance or "",
  855. }
  856. bucket[entity_id] = data
  857. if canonical_name and not data.get("canonical_name"):
  858. data["canonical_name"] = canonical_name
  859. if tier and str(tier).lower() in ENTITY_TIER_MAP:
  860. data["tier"] = tier
  861. if desc:
  862. data["desc"] = desc
  863. if first_appearance and not data.get("first_appearance"):
  864. data["first_appearance"] = first_appearance
  865. data.setdefault("current", {})
  866. data.setdefault("history", [])
  867. data.setdefault("aliases", [])
  868. return data
  869. def _apply_operations(entity_type: str, entity_id: str, data: dict, operations: List[Dict[str, Any]]) -> None:
  870. """应用实体更新操作(v4.0 支持 set/unset/add/remove/inc + 顶层字段)"""
  871. if not operations:
  872. return
  873. current = data.setdefault("current", {})
  874. changes: Dict[str, Any] = {}
  875. reasons: Dict[str, str] = {}
  876. def _rename(new_name: str, reason: str = "") -> None:
  877. new_name = str(new_name or "").strip()
  878. if not new_name:
  879. return
  880. old_name = str(data.get("canonical_name", "")).strip()
  881. if old_name and old_name != new_name:
  882. _register_alias(entity_type, entity_id, old_name, first_seen=first_seen_chapter)
  883. data["canonical_name"] = new_name
  884. _register_alias(entity_type, entity_id, new_name, first_seen=first_seen_chapter)
  885. changes["canonical_name"] = new_name
  886. if reason:
  887. reasons["canonical_name"] = reason
  888. for op_item in operations:
  889. op = str(op_item.get("op", "set")).strip().lower()
  890. key = str(op_item.get("key", "")).strip()
  891. reason = str(op_item.get("reason", "")).strip()
  892. if not key:
  893. continue
  894. # 顶层字段处理
  895. if key in TOP_LEVEL_FIELDS:
  896. if op == "set":
  897. value = str(op_item.get("value", "")).strip()
  898. if key == "canonical_name":
  899. _rename(value, reason)
  900. elif key == "tier":
  901. # 校验 tier 值
  902. if value.lower() in ENTITY_TIER_MAP or value in {"核心", "支线", "装饰"}:
  903. if data.get("tier") != value:
  904. data["tier"] = value
  905. changes["tier"] = value
  906. if reason:
  907. reasons["tier"] = reason
  908. else:
  909. print(f"⚠️ 无效 tier 值: {value},跳过")
  910. else:
  911. if data.get(key) != value:
  912. data[key] = value
  913. changes[key] = value
  914. if reason:
  915. reasons[key] = reason
  916. elif op == "unset":
  917. if key in data:
  918. del data[key]
  919. changes[key] = None
  920. if reason:
  921. reasons[key] = reason
  922. continue
  923. # canonical_name 的特殊别名
  924. if key in {"name", "canonical_name"} and op == "set":
  925. value = str(op_item.get("value", "")).strip()
  926. _rename(value, reason)
  927. continue
  928. # current 字段操作
  929. if op == "set":
  930. value = str(op_item.get("value", "")).strip()
  931. prev = current.get(key)
  932. if prev != value:
  933. current[key] = value
  934. changes[key] = value
  935. if reason:
  936. reasons[key] = reason
  937. elif op == "unset":
  938. if key in current:
  939. del current[key]
  940. changes[key] = None
  941. if reason:
  942. reasons[key] = reason
  943. elif op == "add":
  944. value = str(op_item.get("value", "")).strip()
  945. if not value:
  946. continue
  947. arr = current.get(key, [])
  948. if not isinstance(arr, list):
  949. arr = [arr] if arr else []
  950. if value not in arr:
  951. arr.append(value)
  952. current[key] = arr
  953. changes[key] = arr
  954. if reason:
  955. reasons[key] = reason
  956. elif op == "remove":
  957. value = str(op_item.get("value", "")).strip()
  958. if not value:
  959. continue
  960. arr = current.get(key, [])
  961. if isinstance(arr, list) and value in arr:
  962. arr.remove(value)
  963. current[key] = arr
  964. changes[key] = arr
  965. if reason:
  966. reasons[key] = reason
  967. elif op == "inc":
  968. delta = op_item.get("delta", 1)
  969. try:
  970. delta = int(delta)
  971. except (TypeError, ValueError):
  972. delta = 1
  973. prev = current.get(key, 0)
  974. try:
  975. prev = int(prev)
  976. except (TypeError, ValueError):
  977. prev = 0
  978. new_val = prev + delta
  979. current[key] = new_val
  980. changes[key] = new_val
  981. if reason:
  982. reasons[key] = reason
  983. if first_seen_chapter:
  984. current["last_chapter"] = max(_to_int(current.get("last_chapter"), 0), first_seen_chapter)
  985. if changes:
  986. entry: Dict[str, Any] = {"chapter": first_seen_chapter or 0, "changes": changes}
  987. if reasons:
  988. entry["reasons"] = reasons
  989. entry["added_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  990. data.setdefault("history", []).append(entry)
  991. # 1) 处理 <entity .../> / <entity>...</entity>
  992. for entity in entities or []:
  993. entity_type = _normalize_entity_type(entity.get("type", ""))
  994. name = str(entity.get("name", "")).strip()
  995. if not name:
  996. continue
  997. raw_id = entity.get("id")
  998. entity_id = (str(raw_id).strip() if raw_id is not None else "") or None
  999. data: Optional[dict] = None
  1000. if entity_id:
  1001. _, _, data = _resolve_by_id(entity_id, entity_type)
  1002. else:
  1003. _, rid, rdata = _resolve_ref(name, entity_type)
  1004. if rid and isinstance(rdata, dict):
  1005. entity_id = rid
  1006. data = rdata
  1007. if not entity_id:
  1008. existing_ids = set((state.get("entities_v3") or {}).get(entity_type, {}).keys())
  1009. entity_id = generate_entity_id(entity_type, name, existing_ids)
  1010. first_appearance = _normalize_first_appearance(entity.get("source_file", ""))
  1011. tier = str(entity.get("tier", "支线")).strip() or "支线"
  1012. if tier.lower() not in ENTITY_TIER_MAP:
  1013. tier = "支线"
  1014. desc = str(entity.get("desc", "")).strip()
  1015. data = _ensure_v3_entity(entity_type, entity_id, name, tier=tier, desc=desc, first_appearance=first_appearance)
  1016. # canonical name & aliases
  1017. _register_alias(entity_type, entity_id, str(data.get("canonical_name", "")).strip() or name, first_seen=first_seen_chapter)
  1018. _register_alias(entity_type, entity_id, name, first_seen=first_seen_chapter)
  1019. for alias in (entity.get("aliases") or []):
  1020. _register_alias(entity_type, entity_id, alias, first_seen=first_seen_chapter)
  1021. # attribute updates (auto mode)
  1022. extra_attrs = entity.get("attrs") or {}
  1023. if isinstance(extra_attrs, dict) and extra_attrs:
  1024. ops = [{"op": "set", "key": k, "value": str(v), "reason": ""} for k, v in extra_attrs.items()]
  1025. _apply_operations(entity_type, entity_id, data, ops)
  1026. touched.add((entity_type, entity_id))
  1027. # 2) 处理 <entity-alias .../>
  1028. for op in entity_alias_ops:
  1029. alias = str(op.get("alias", "")).strip()
  1030. if not alias:
  1031. continue
  1032. hint = op.get("type")
  1033. entity_type_hint = _normalize_entity_type(hint) if hint else None
  1034. et: Optional[str] = None
  1035. eid: Optional[str] = None
  1036. data: Optional[dict] = None
  1037. if op.get("id"):
  1038. et, eid, data = _resolve_by_id(op.get("id"), entity_type_hint)
  1039. elif op.get("ref"):
  1040. et, eid, data = _resolve_ref(op.get("ref"), entity_type_hint)
  1041. if not (et and eid and isinstance(data, dict)):
  1042. print(f"?? entity-alias 无法解析引用: id={op.get('id')!r} ref={op.get('ref')!r}")
  1043. continue
  1044. _register_alias(et, eid, alias, context=str(op.get("context", "")).strip(), first_seen=first_seen_chapter)
  1045. touched.add((et, eid))
  1046. # 3) 处理 <entity-update>...</entity-update>
  1047. for op in entity_update_ops:
  1048. operations = op.get("operations") or []
  1049. if not isinstance(operations, list) or not operations:
  1050. continue
  1051. hint = op.get("type")
  1052. entity_type_hint = _normalize_entity_type(hint) if hint else None
  1053. et: Optional[str] = None
  1054. eid: Optional[str] = None
  1055. data: Optional[dict] = None
  1056. if op.get("id"):
  1057. et, eid, data = _resolve_by_id(op.get("id"), entity_type_hint)
  1058. elif op.get("ref"):
  1059. et, eid, data = _resolve_ref(op.get("ref"), entity_type_hint)
  1060. if not (et and eid and isinstance(data, dict)):
  1061. print(f"⚠️ entity-update 无法解析引用: id={op.get('id')!r} ref={op.get('ref')!r}")
  1062. continue
  1063. _apply_operations(et, eid, data, operations)
  1064. touched.add((et, eid))
  1065. # 4) 更新金手指技能
  1066. if golden_finger_skills:
  1067. existing = state['protagonist_state']['golden_finger'].get('skills', [])
  1068. if not isinstance(existing, list):
  1069. existing = []
  1070. state['protagonist_state']['golden_finger']['skills'] = existing
  1071. existing_by_name = {s.get("name"): s for s in existing if isinstance(s, dict) and s.get("name")}
  1072. for skill in golden_finger_skills:
  1073. if not isinstance(skill, dict):
  1074. continue
  1075. name = str(skill.get("name", "")).strip()
  1076. if not name:
  1077. continue
  1078. level = str(skill.get("level", "")).strip()
  1079. desc = str(skill.get("desc", "")).strip()
  1080. cooldown = str(skill.get("cooldown", "")).strip()
  1081. source_file = str(skill.get("source_file", "")).strip()
  1082. existing_skill = existing_by_name.get(name)
  1083. if existing_skill is None:
  1084. new_skill = {
  1085. "name": name,
  1086. "level": level,
  1087. "desc": desc,
  1088. "cooldown": cooldown,
  1089. "unlocked_at": source_file,
  1090. "added_at": datetime.now().strftime('%Y-%m-%d')
  1091. }
  1092. existing.append(new_skill)
  1093. existing_by_name[name] = new_skill
  1094. print(f" ✨ 新增金手指技能: {name} ({level})")
  1095. continue
  1096. changed = False
  1097. if level and existing_skill.get("level") != level:
  1098. existing_skill["level"] = level
  1099. changed = True
  1100. if desc and existing_skill.get("desc") != desc:
  1101. existing_skill["desc"] = desc
  1102. changed = True
  1103. if cooldown and existing_skill.get("cooldown") != cooldown:
  1104. existing_skill["cooldown"] = cooldown
  1105. changed = True
  1106. if source_file and not existing_skill.get("unlocked_at"):
  1107. existing_skill["unlocked_at"] = source_file
  1108. changed = True
  1109. if changed:
  1110. existing_skill["updated_at"] = datetime.now().strftime('%Y-%m-%d')
  1111. print(f" 🔁 更新金手指技能: {name} ({existing_skill.get('level', level)})")
  1112. # 更新伏笔(结构化)
  1113. if foreshadowing_items:
  1114. state.setdefault("plot_threads", {"active_threads": [], "foreshadowing": []})
  1115. state["plot_threads"].setdefault("foreshadowing", [])
  1116. existing = state["plot_threads"]["foreshadowing"]
  1117. for item in foreshadowing_items:
  1118. content = str(item.get("content", "")).strip()
  1119. if not content:
  1120. continue
  1121. planted = item.get("planted_chapter") or default_planted_chapter or 1
  1122. try:
  1123. planted = int(planted)
  1124. except (TypeError, ValueError):
  1125. planted = default_planted_chapter or 1
  1126. target = item.get("target_chapter")
  1127. if target is None:
  1128. target = planted + 100
  1129. try:
  1130. target = int(target)
  1131. except (TypeError, ValueError):
  1132. target = planted + 100
  1133. tier = str(item.get("tier", "支线")).strip() or "支线"
  1134. if tier.lower() not in ENTITY_TIER_MAP:
  1135. tier = "支线"
  1136. location = str(item.get("location", "")).strip()
  1137. characters = item.get("characters", [])
  1138. if not isinstance(characters, list):
  1139. characters = []
  1140. found = None
  1141. for old in existing:
  1142. if old.get("content") == content:
  1143. found = old
  1144. break
  1145. if found is None:
  1146. existing.append({
  1147. "content": content,
  1148. "status": "未回收",
  1149. "tier": tier,
  1150. "planted_chapter": planted,
  1151. "target_chapter": target,
  1152. "location": location,
  1153. "characters": characters,
  1154. "added_at": datetime.now().strftime("%Y-%m-%d"),
  1155. })
  1156. print(f" ?? 新增伏笔: {content[:30]}...")
  1157. else:
  1158. found["tier"] = tier
  1159. found["planted_chapter"] = planted
  1160. found["target_chapter"] = target
  1161. if location:
  1162. found["location"] = location
  1163. old_chars = found.get("characters", [])
  1164. if not isinstance(old_chars, list):
  1165. old_chars = []
  1166. merged = []
  1167. seen = set()
  1168. for n in [*old_chars, *characters]:
  1169. s = str(n).strip()
  1170. if not s or s in seen:
  1171. continue
  1172. merged.append(s)
  1173. seen.add(s)
  1174. found["characters"] = merged
  1175. # 更新关系(结构化,推荐使用 entity_id)
  1176. if relationship_items:
  1177. state.setdefault("structured_relationships", [])
  1178. existing = state["structured_relationships"]
  1179. for item in relationship_items:
  1180. # 优先使用显式 entity_id;否则按别名解析(强制消歧)
  1181. char1_id = str(item.get("char1_id", "") or "").strip()
  1182. char2_id = str(item.get("char2_id", "") or "").strip()
  1183. char1_ref = str(item.get("char1", "")).strip()
  1184. char2_ref = str(item.get("char2", "")).strip()
  1185. # relationship 只允许角色
  1186. if char1_id:
  1187. _, rid, rdata = _resolve_by_id(char1_id, "角色")
  1188. if not rid or not isinstance(rdata, dict):
  1189. raise ValueError(f"relationship.char1_id 无法解析: {char1_id!r}")
  1190. char1_id = rid
  1191. char1_name = str(rdata.get("canonical_name", "")).strip() or char1_ref
  1192. else:
  1193. _, rid, rdata = _resolve_ref(char1_ref, "角色")
  1194. if not rid or not isinstance(rdata, dict):
  1195. raise ValueError(f"relationship.char1 无法解析: {char1_ref!r}")
  1196. char1_id = rid
  1197. char1_name = str(rdata.get("canonical_name", "")).strip() or char1_ref
  1198. if char2_id:
  1199. _, rid, rdata = _resolve_by_id(char2_id, "角色")
  1200. if not rid or not isinstance(rdata, dict):
  1201. raise ValueError(f"relationship.char2_id 无法解析: {char2_id!r}")
  1202. char2_id = rid
  1203. char2_name = str(rdata.get("canonical_name", "")).strip() or char2_ref
  1204. else:
  1205. _, rid, rdata = _resolve_ref(char2_ref, "角色")
  1206. if not rid or not isinstance(rdata, dict):
  1207. raise ValueError(f"relationship.char2 无法解析: {char2_ref!r}")
  1208. char2_id = rid
  1209. char2_name = str(rdata.get("canonical_name", "")).strip() or char2_ref
  1210. rel_type = str(item.get("type", "ally")).strip().lower() or "ally"
  1211. intensity = item.get("intensity", 50)
  1212. desc = str(item.get("desc", "")).strip()
  1213. try:
  1214. intensity = int(intensity)
  1215. intensity = max(0, min(100, intensity))
  1216. except (TypeError, ValueError):
  1217. intensity = 50
  1218. # 查找是否已存在相同关系
  1219. found = None
  1220. for old in existing:
  1221. if (
  1222. old.get("char1_id") == char1_id
  1223. and old.get("char2_id") == char2_id
  1224. and old.get("type") == rel_type
  1225. ):
  1226. found = old
  1227. break
  1228. if found is None:
  1229. existing.append({
  1230. "char1_id": char1_id,
  1231. "char2_id": char2_id,
  1232. "char1_name": char1_name,
  1233. "char2_name": char2_name,
  1234. "type": rel_type,
  1235. "intensity": intensity,
  1236. "description": desc,
  1237. "last_update_chapter": default_planted_chapter or 1,
  1238. "added_at": datetime.now().strftime("%Y-%m-%d"),
  1239. })
  1240. print(f" 💕 新增关系: {char1_name} ↔ {char2_name} ({rel_type}, 强度 {intensity})")
  1241. else:
  1242. # 更新强度和描述
  1243. found["intensity"] = intensity
  1244. found["description"] = desc
  1245. found["last_update_chapter"] = default_planted_chapter or found.get("last_update_chapter", 1)
  1246. found.setdefault("char1_name", char1_name)
  1247. found.setdefault("char2_name", char2_name)
  1248. print(f" 💕 更新关系: {char1_name} ↔ {char2_name} ({rel_type}, 强度 {intensity})")
  1249. # 使用集中式原子写入(带 filelock + 自动备份)
  1250. atomic_write_json(state_file, state, use_lock=True, backup=True)
  1251. print(f"✅ state.json 已原子化更新(带备份)")
  1252. def sync_entity_to_settings(entity: Dict, project_root: str, auto_mode: bool = False) -> bool:
  1253. """
  1254. 将实体同步到设定集
  1255. Returns:
  1256. bool: 是否成功同步
  1257. """
  1258. entity_type = normalize_entity_type(entity.get('type'))
  1259. entity_name = entity['name']
  1260. if entity_type == "角色":
  1261. category = categorize_character(entity['desc'])
  1262. category_dir = ROLE_CATEGORY_MAP.get(category.split('/')[0], "次要角色")
  1263. target_dir = Path(project_root) / f"设定集/角色库/{category_dir}"
  1264. # ============================================================================
  1265. # 安全修复:使用安全目录创建函数(文件权限修复)
  1266. # ============================================================================
  1267. create_secure_directory(str(target_dir))
  1268. # ============================================================================
  1269. # 安全修复:清理文件名,防止路径遍历 (CWE-22) - P0 CRITICAL
  1270. # 原代码: target_file = target_dir / f"{entity_name}.md"
  1271. # 漏洞: entity_name可能包含 "../" 导致目录遍历攻击
  1272. # ============================================================================
  1273. safe_entity_name = sanitize_filename(entity_name)
  1274. target_file = target_dir / f"{safe_entity_name}.md"
  1275. if target_file.exists():
  1276. print(f"⚠️ 角色卡已存在: {target_file}")
  1277. if not auto_mode:
  1278. choice = input("是否覆盖?(y/n): ")
  1279. if choice.lower() != 'y':
  1280. return False
  1281. with open(target_file, 'w', encoding='utf-8') as f:
  1282. f.write(generate_character_card(entity, category))
  1283. print(f"✅ 已创建角色卡: {target_file}")
  1284. return True
  1285. elif entity_type == "地点":
  1286. target_file = Path(project_root) / "设定集/世界观.md"
  1287. update_world_view(entity, str(target_file), "地理")
  1288. print(f"✅ 已更新世界观(地理): {entity_name}")
  1289. return True
  1290. elif entity_type == "势力":
  1291. target_file = Path(project_root) / "设定集/世界观.md"
  1292. update_world_view(entity, str(target_file), "势力")
  1293. print(f"✅ 已更新世界观(势力): {entity_name}")
  1294. return True
  1295. elif entity_type == "招式":
  1296. target_file = Path(project_root) / "设定集/力量体系.md"
  1297. update_power_system(entity, str(target_file))
  1298. print(f"✅ 已更新力量体系(招式): {entity_name}")
  1299. return True
  1300. elif entity_type == "物品":
  1301. target_dir = Path(project_root) / "设定集/物品库"
  1302. # ============================================================================
  1303. # 安全修复:使用安全目录创建函数(文件权限修复)
  1304. # ============================================================================
  1305. create_secure_directory(str(target_dir))
  1306. # ============================================================================
  1307. # 安全修复:清理文件名,防止路径遍历 (CWE-22) - P0 CRITICAL
  1308. # 原代码: target_file = target_dir / f"{entity_name}.md"
  1309. # 漏洞: entity_name可能包含 "../" 导致目录遍历攻击
  1310. # ============================================================================
  1311. safe_entity_name = sanitize_filename(entity_name)
  1312. target_file = target_dir / f"{safe_entity_name}.md"
  1313. if target_file.exists():
  1314. print(f"⚠️ 物品卡已存在: {target_file}")
  1315. if not auto_mode:
  1316. choice = input("是否覆盖?(y/n): ")
  1317. if choice.lower() != 'y':
  1318. return False
  1319. content = f"""# {entity_name}
  1320. > **首次登场**: {entity.get('source_file', '未知')}
  1321. > **创建时间**: {datetime.now().strftime('%Y-%m-%d')}
  1322. ## 基本信息
  1323. {entity['desc']}
  1324. ## 详细设定
  1325. 待补充
  1326. ## 相关剧情
  1327. - 【第 X 章】首次出现
  1328. ## 备注
  1329. 自动提取自 `<entity/>` 标签,请补充完善。
  1330. """
  1331. with open(target_file, 'w', encoding='utf-8') as f:
  1332. f.write(content)
  1333. print(f"✅ 已创建物品卡: {target_file}")
  1334. return True
  1335. else:
  1336. print(f"⚠️ 未知实体类型: {entity_type}")
  1337. return False
  1338. def main():
  1339. parser = argparse.ArgumentParser(
  1340. description="XML 标签提取与同步 (<entity/>, <entity-alias/>, <entity-update>, <skill/>, <foreshadow/>, <deviation/>, <relationship/>)",
  1341. formatter_class=argparse.RawDescriptionHelpFormatter,
  1342. epilog="""
  1343. 示例:
  1344. # 指定文件(兼容卷目录)
  1345. python extract_entities.py "webnovel-project/正文/第1卷/第001章-死亡降临.md" --auto
  1346. # 指定章节号(推荐)
  1347. python extract_entities.py --project-root "webnovel-project" --chapter 1 --auto
  1348. """.strip(),
  1349. )
  1350. parser.add_argument("chapter_file", nargs="?", help="章节文件路径(或使用 --chapter)")
  1351. parser.add_argument("--chapter", type=int, help="章节号(与 --project-root 配合,自动定位章节文件)")
  1352. parser.add_argument("--project-root", default=None, help="项目根目录(包含 .webnovel/state.json)")
  1353. parser.add_argument("--auto", action="store_true", help="自动模式(非交互)")
  1354. parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入文件/状态")
  1355. args = parser.parse_args()
  1356. auto_mode = args.auto
  1357. dry_run = args.dry_run
  1358. project_root: Optional[Path] = None
  1359. if args.project_root:
  1360. project_root = resolve_project_root(args.project_root)
  1361. else:
  1362. try:
  1363. project_root = resolve_project_root()
  1364. except FileNotFoundError:
  1365. project_root = None
  1366. chapter_file: Optional[str] = None
  1367. chapter_num: Optional[int] = None
  1368. if args.chapter is not None:
  1369. if not project_root:
  1370. print("❌ 未提供有效的 --project-root,无法用 --chapter 定位章节文件")
  1371. sys.exit(1)
  1372. chapter_num = int(args.chapter)
  1373. chapter_path = find_chapter_file(project_root, chapter_num)
  1374. if not chapter_path:
  1375. print(f"❌ 未找到第{chapter_num}章文件(请先生成/保存章节)")
  1376. sys.exit(1)
  1377. chapter_file = str(chapter_path)
  1378. else:
  1379. if not args.chapter_file:
  1380. parser.error("必须提供 chapter_file 或 --chapter")
  1381. chapter_file = args.chapter_file
  1382. if not os.path.exists(chapter_file):
  1383. print(f"❌ 文件不存在: {chapter_file}")
  1384. sys.exit(1)
  1385. chapter_num = extract_chapter_num_from_filename(Path(chapter_file).name)
  1386. print(f"📖 正在扫描: {chapter_file}")
  1387. entities = extract_new_entities(chapter_file)
  1388. entity_alias_ops = extract_entity_alias_ops(chapter_file)
  1389. entity_update_ops = extract_entity_update_ops(chapter_file)
  1390. golden_finger_skills = extract_golden_finger_skills(chapter_file)
  1391. foreshadowing_items = extract_foreshadowing_json(chapter_file)
  1392. deviations = extract_deviations(chapter_file)
  1393. relationship_items = extract_relationships(chapter_file)
  1394. 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:
  1395. print("✅ 未发现任何 XML 标签(<entity>/<entity-alias>/<entity-update>/<skill>/<foreshadow>/<deviation>/<relationship>)")
  1396. return
  1397. if entities:
  1398. print(f"\n🔍 发现 {len(entities)} 个新实体:")
  1399. for i, entity in enumerate(entities, 1):
  1400. tier_emoji = {"核心": "🔴", "支线": "🟡", "装饰": "🟢"}.get(entity.get("tier", "支线"), "⚪")
  1401. print(
  1402. f" {i}. [{entity['type']}] {entity['name']} {tier_emoji}{entity.get('tier', '支线')} - {entity['desc'][:25]}..."
  1403. )
  1404. if golden_finger_skills:
  1405. print(f"\n✨ 发现 {len(golden_finger_skills)} 个金手指技能:")
  1406. for i, skill in enumerate(golden_finger_skills, 1):
  1407. print(f" {i}. {skill['name']} ({skill['level']}) - {skill['desc'][:25]}...")
  1408. if entity_alias_ops:
  1409. print(f"\n🏷️ 发现 {len(entity_alias_ops)} 条实体别名:")
  1410. for i, op in enumerate(entity_alias_ops, 1):
  1411. ref = op.get("id") or op.get("ref") or "?"
  1412. print(f" {i}. {ref} -> {op.get('alias', '')}")
  1413. if entity_update_ops:
  1414. print(f"\n🛠️ 发现 {len(entity_update_ops)} 条实体更新:")
  1415. for i, op in enumerate(entity_update_ops, 1):
  1416. ref = op.get("id") or op.get("ref") or "?"
  1417. operations = op.get("operations") or []
  1418. ops_preview = []
  1419. for o in operations[:6]:
  1420. if isinstance(o, dict):
  1421. op_type = o.get("op", "set")
  1422. key = o.get("key", "")
  1423. ops_preview.append(f"{op_type}:{key}")
  1424. preview = ", ".join(ops_preview) + ("..." if len(operations) > 6 else "")
  1425. print(f" {i}. {ref}: {preview}")
  1426. if foreshadowing_items:
  1427. print(f"\n🧩 发现 {len(foreshadowing_items)} 条伏笔:")
  1428. for i, item in enumerate(foreshadowing_items, 1):
  1429. tier = item.get("tier", "支线")
  1430. target = item.get("target_chapter", "未设定")
  1431. print(f" {i}. {tier} → 目标Ch{target}: {str(item.get('content', ''))[:40]}...")
  1432. if deviations:
  1433. print(f"\n⚡ 发现 {len(deviations)} 条大纲偏离:")
  1434. for i, dev in enumerate(deviations, 1):
  1435. print(f" {i}. {dev.get('reason', '')[:50]}...")
  1436. if relationship_items:
  1437. print(f"\n💕 发现 {len(relationship_items)} 条关系:")
  1438. for i, rel in enumerate(relationship_items, 1):
  1439. char1 = str(rel.get("char1") or rel.get("char1_id") or "").strip() or "?"
  1440. char2 = str(rel.get("char2") or rel.get("char2_id") or "").strip() or "?"
  1441. print(f" {i}. {char1} ↔ {char2} ({rel['type']}, 强度 {rel['intensity']})")
  1442. if dry_run:
  1443. print("\n⚠️ Dry-run 模式,不执行实际写入")
  1444. return
  1445. if not project_root:
  1446. chapter_path = Path(chapter_file).resolve()
  1447. for parent in [chapter_path.parent] + list(chapter_path.parents):
  1448. if (parent / ".webnovel" / "state.json").exists():
  1449. project_root = parent
  1450. break
  1451. if not project_root:
  1452. print("❌ 找不到项目根目录(缺少 .webnovel/state.json)")
  1453. print("请先运行 /webnovel-init 初始化项目,或使用 --project-root 指定路径")
  1454. sys.exit(1)
  1455. state_file = resolve_state_file(explicit_project_root=str(project_root))
  1456. print("\n📝 开始同步到设定集...")
  1457. success_count = 0
  1458. for entity in entities:
  1459. if sync_entity_to_settings(entity, str(project_root), auto_mode):
  1460. success_count += 1
  1461. print("\n💾 更新 state.json...")
  1462. try:
  1463. update_state_json(
  1464. entities=entities,
  1465. state_file=str(state_file),
  1466. golden_finger_skills=golden_finger_skills,
  1467. foreshadowing_items=foreshadowing_items,
  1468. relationship_items=relationship_items,
  1469. entity_alias_ops=entity_alias_ops,
  1470. entity_update_ops=entity_update_ops,
  1471. default_planted_chapter=chapter_num,
  1472. )
  1473. except (AmbiguousAliasError, ValueError) as e:
  1474. print(f"❌ {e}")
  1475. sys.exit(2)
  1476. print("\n✅ 完成!")
  1477. print(f" - 实体同步: {success_count}/{len(entities)} 个")
  1478. if golden_finger_skills:
  1479. print(f" - 金手指技能: {len(golden_finger_skills)} 个")
  1480. if foreshadowing_items:
  1481. print(f" - 伏笔同步: {len(foreshadowing_items)} 条")
  1482. if relationship_items:
  1483. print(f" - 关系同步: {len(relationship_items)} 条")
  1484. if deviations:
  1485. print(f" - 大纲偏离: {len(deviations)} 条(仅记录,不同步到 state.json)")
  1486. if not auto_mode:
  1487. print("\n💡 建议:")
  1488. print(" 1. 检查生成的角色卡/物品卡,补充详细设定")
  1489. print(" 2. 查看 世界观.md 和 力量体系.md 的更新")
  1490. print(" 3. 确认 .webnovel/state.json 中的实体记录")
  1491. if golden_finger_skills:
  1492. print(" 4. 检查金手指技能是否正确记录在 protagonist_state.golden_finger.skills")
  1493. if foreshadowing_items:
  1494. print(" 5. 检查 plot_threads.foreshadowing 的 planted/target/tier/location/characters 是否合理")
  1495. if relationship_items:
  1496. print(" 6. 检查 structured_relationships 关系记录是否合理")
  1497. if deviations:
  1498. print(" 7. 大纲偏离已记录,请在 plan.md 或大纲中同步调整")
  1499. if __name__ == "__main__":
  1500. main()